Merge remote-tracking branch 'origin/main' into feat/onboarding-survey-trim

This commit is contained in:
sai nikhil kethe
2026-10-28 19:16:58 +05:30
69 changed files with 2957 additions and 339 deletions
@@ -20128,6 +20128,7 @@
"resetFocus": "52e4a8f135999837",
"resetZoom": "3a43acdce0ce0a6c",
"rootAtBottom": "c56857a7058a5f54",
"runNeedsProfileType": "cce3ebf0fef22891",
"runOptions": "351dabc4ae8669a2",
"runQuery": "1837e00c45e8663d",
"searchForFunction": "47fc3e0c4a4f8aa6",
+4 -1
View File
@@ -3798,7 +3798,6 @@ mod tests {
"custom_logo_text",
"editor_suggest_mode",
"native_login_enabled",
"rum",
"sso_enabled",
"telemetry_enabled",
],
@@ -3877,6 +3876,10 @@ mod tests {
payload.get("version").is_some(),
"authenticated config carries the version"
);
assert!(
payload.get("rum").is_some_and(Value::is_object),
"the RUM SDK settings are served only to signed-in callers"
);
assert!(
payload.get("instance").is_none(),
"the instance id is a fingerprinting handle no UI consumer reads; it must not be served"
@@ -319,7 +319,6 @@ struct ConfigBootstrapResponse {
custom_hide_self_logo: bool,
/// The editors need it before the authenticated config lands, or `off` arrives too late.
editor_suggest_mode: String,
rum: Rum,
}
#[derive(Serialize, serde::Deserialize)]
@@ -462,7 +461,6 @@ pub async fn zo_config_bootstrap() -> impl IntoResponse {
custom_logo_dark_img,
custom_hide_self_logo,
editor_suggest_mode: cfg.limit.editor_suggest_mode.clone(),
rum: Rum::from_cfg(cfg.clone()),
})
}
+20
View File
@@ -66,7 +66,10 @@ fn default_eq_op() -> String {
#[derive(Debug, Clone, Default, Deserialize, Serialize, ToSchema)]
pub struct ProfilesQueryBody {
/// Missing reads as 0 so `parse_required_time_range` reports it, not the JSON parser.
#[serde(default)]
pub start_time: i64,
#[serde(default)]
pub end_time: i64,
#[serde(default)]
pub data_source: Option<String>,
@@ -651,4 +654,21 @@ mod tests {
let response = SearchResponse::default();
assert!(reject_partial_search_response(response).is_ok());
}
#[tokio::test]
async fn post_body_without_time_range_gets_the_required_time_range_message() {
let body: ProfilesQueryBody =
json::from_str(r#"{"profile_type":"cpu","profile_unit":"nanoseconds"}"#).unwrap();
let body = resolve_query_body(&Method::POST, Ok(Json(body)), &HashMap::new()).unwrap();
let resp = parse_required_time_range(body.start_time, body.end_time).unwrap_err();
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
text.contains("start_time and end_time are required (microseconds)"),
"{text}"
);
}
}
+119 -16
View File
@@ -15,14 +15,16 @@
use arrow_schema::Schema;
use config::{
TIMESTAMP_COL_NAME,
TIMESTAMP_COL_NAME, get_config,
meta::search::{Query, Request, SearchEventType},
utils::time::now_micros,
};
use hashbrown::HashMap;
use super::{
query::{build_llm_trace_query, build_trace_query, trace_id_list_sql},
query::{
DURATION_CANDIDATE_SELECT, build_llm_trace_query, build_trace_query, trace_id_list_sql,
},
response::TraceResponseItem,
};
use crate::{common::utils::http::get_use_cache_from_request, traces::schema_compat};
@@ -73,7 +75,7 @@ impl TraceListPlan {
let mut request = base_request.clone();
request.query.sql = format!(
"{} WHERE trace_id IN ({}) GROUP BY trace_id",
self.select_sql(stream_name),
self.select_sql(stream_name, false),
trace_id_list_sql(trace_ids)
);
request.query.from = 0;
@@ -93,9 +95,9 @@ impl TraceListPlan {
}
fn sql(&self, stream_name: &str) -> String {
let sql = self.select_sql(stream_name);
let sql = self.select_sql(stream_name, self.sort.ranks_by_candidate(self.mode));
let filter = self.filter();
let order = self.sort.sql();
let order = self.sort.sql(self.mode);
if filter.is_empty() {
format!("{sql} GROUP BY trace_id ORDER BY {order}")
} else {
@@ -103,8 +105,12 @@ impl TraceListPlan {
}
}
fn select_sql(&self, stream_name: &str) -> String {
let extra_selects = self.columns.aggregate_selects();
// Only the streamed descending duration Q1 reads the candidate; Q2a and other sorts skip it.
fn select_sql(&self, stream_name: &str, with_candidate: bool) -> String {
let mut extra_selects = self.columns.aggregate_selects();
if with_candidate {
extra_selects = format!("{DURATION_CANDIDATE_SELECT}, {extra_selects}");
}
match &self.llm_schema {
Some(llm_schema) => build_llm_trace_query(stream_name, llm_schema, &extra_selects),
None => build_trace_query(stream_name, &extra_selects),
@@ -154,14 +160,17 @@ impl TraceListOptions {
if end == 0 {
return Err("end_time is empty".to_string());
}
let size = query
.get("size")
.and_then(|v| v.parse::<i64>().ok())
.filter(|size| *size > 0)
.unwrap_or_else(|| get_config().limit.query_default_limit);
let mut options = Self {
filter,
from: query
.get("from")
.map_or(0, |v| v.parse::<i64>().unwrap_or(0)),
size: query
.get("size")
.map_or(10, |v| v.parse::<i64>().unwrap_or(10)),
size,
range: (start, end),
timeout: query
.get("timeout")
@@ -249,7 +258,11 @@ impl TraceSort {
})
}
pub fn sql(self) -> String {
pub fn sql(self, mode: TraceListMode) -> String {
if self.ranks_by_candidate(mode) {
// Same tiebreaker as the merge, so every page's per-partition LIMIT cuts ties alike.
return "zo_sql_candidate DESC, trace_id ASC".to_string();
}
let field = if self.by_duration {
"zo_sql_duration"
} else {
@@ -259,6 +272,11 @@ impl TraceSort {
format!("{field} {order}")
}
// A crossing trace reaches the candidate (longest span or local range) in one partition.
pub fn ranks_by_candidate(self, mode: TraceListMode) -> bool {
mode == TraceListMode::Streaming && self.by_duration && !self.ascending
}
pub fn sort(self, hits: &mut [TraceResponseItem]) {
let key = |hit: &TraceResponseItem| {
if self.by_duration {
@@ -343,17 +361,43 @@ mod tests {
}
#[test]
fn parsing_preserves_defaults_and_unclamped_pagination() {
fn parsing_preserves_defaults_and_unclamped_offset() {
let limit = get_config().limit.query_default_limit;
for mode in [TraceListMode::Complete, TraceListMode::Streaming] {
let mut query = query();
let defaults = TraceListOptions::parse(&query, mode).unwrap();
assert_eq!((defaults.from, defaults.size, defaults.timeout), (0, 10, 0));
assert_eq!(
(defaults.from, defaults.size, defaults.timeout),
(0, limit, 0)
);
query.insert("from".to_string(), "-2".to_string());
query.insert("size".to_string(), "-1".to_string());
let options = TraceListOptions::parse(&query, mode).unwrap();
assert_eq!((options.from, options.size), (-2, -1));
query.insert("size".to_string(), "invalid".to_string());
assert_eq!(TraceListOptions::parse(&query, mode).unwrap().size, 10);
assert_eq!((options.from, options.size), (-2, limit));
}
}
#[test]
fn sizes_below_one_resolve_to_the_query_default_limit() {
let limit = get_config().limit.query_default_limit;
for (size, expected) in [
(None, limit),
(Some("0"), limit),
(Some("-1"), limit),
(Some("-5"), limit),
(Some("invalid"), limit),
(Some("1"), 1),
(Some("2"), 2),
(Some("2000"), 2000),
] {
let mut query = query();
if let Some(size) = size {
query.insert("size".to_string(), size.to_string());
}
for mode in [TraceListMode::Complete, TraceListMode::Streaming] {
let options = TraceListOptions::parse(&query, mode).unwrap();
assert_eq!(options.size, expected, "{size:?} {mode:?}");
}
}
}
@@ -455,6 +499,65 @@ mod tests {
}
}
#[test]
fn only_the_streamed_descending_duration_sort_ranks_by_the_candidate() {
for (sort_order, expected) in [
(
"desc",
[
"zo_sql_duration DESC",
"zo_sql_candidate DESC, trace_id ASC",
],
),
("asc", ["zo_sql_duration ASC", "zo_sql_duration ASC"]),
] {
let mut query = query();
query.insert("sort_by".to_string(), "duration".to_string());
query.insert("sort_order".to_string(), sort_order.to_string());
let llm_schemas = [
None,
Some(schema_compat::ValidatedLlmSchema::fallback(true)),
Some(schema_compat::ValidatedLlmSchema::fallback(false)),
];
let modes = [TraceListMode::Complete, TraceListMode::Streaming];
for ((mode, expected), llm_schema) in modes
.into_iter()
.zip(expected)
.flat_map(|mode| llm_schemas.clone().map(|llm| (mode, llm)))
{
let plan = TraceListPlan {
options: TraceListOptions::parse(&query, mode).unwrap(),
sort: TraceSort::parse(&query).unwrap(),
columns: TraceColumns::default(),
llm_schema,
mode,
};
let sql = plan.sql("traces");
assert!(
sql.ends_with(&format!("GROUP BY trace_id ORDER BY {expected}")),
"{sql}"
);
assert_eq!(
sql.contains(DURATION_CANDIDATE_SELECT),
expected.starts_with("zo_sql_candidate"),
"{sql}"
);
let summary =
plan.summary_request("traces", &Request::default(), &["a".to_string()], (1, 2));
assert!(
!summary.query.sql.contains("zo_sql_candidate"),
"{}",
summary.query.sql
);
}
}
let mut query = query();
query.insert("sort_by".to_string(), "start_time".to_string());
let sort = TraceSort::parse(&query).unwrap();
assert!(!sort.ranks_by_candidate(TraceListMode::Streaming));
assert_eq!(sort.sql(TraceListMode::Streaming), "zo_sql_timestamp DESC");
}
#[test]
fn only_filtered_pages_and_the_streamed_duration_sort_recompute_every_trace() {
for (sort_by, filter, expected) in [
+3
View File
@@ -39,6 +39,9 @@ use crate::traces::{
// The lookup only widens summaries, so a slow index must not hold back the page.
const RANGE_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
// `duration` is in microseconds while `start_time` and `end_time` are in nanoseconds.
pub const DURATION_CANDIDATE_SELECT: &str =
"GREATEST(max(duration), (max(end_time) - min(start_time)) / 1000) AS zo_sql_candidate";
pub struct TraceSearchContext {
pub org_id: String,
+223 -16
View File
@@ -13,7 +13,7 @@
// 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::HashSet;
use std::collections::{HashMap, HashSet};
use axum::response::Response;
use config::{
@@ -36,6 +36,80 @@ use super::{
};
use crate::search::error_utils::map_error_to_http_response;
#[cfg_attr(test, derive(Clone))]
struct CandidateMerge {
cap: usize,
// Largest candidate per trace id, kept for pruned ids too, so a smaller row cannot re-enter.
candidates: HashMap<String, i64>,
rows: HashMap<String, json::Value>,
}
impl CandidateMerge {
fn new(cap: usize) -> Self {
Self {
cap,
candidates: HashMap::new(),
rows: HashMap::new(),
}
}
fn push_hits(&mut self, hits: Vec<json::Value>) {
for hit in hits {
let Some(trace_id) = trace_id_of(&hit)
.filter(|id| !id.is_empty())
.map(str::to_string)
else {
continue;
};
let candidate = candidate_of(&hit);
// A map, not the TopKHeap: a later partition can report a larger candidate for an id.
if self
.candidates
.get(&trace_id)
.is_some_and(|best| *best >= candidate)
{
continue;
}
self.candidates.insert(trace_id.clone(), candidate);
self.rows.insert(trace_id, hit);
}
self.prune();
}
// Dropping past the cap is safe: candidates never shrink, so the cap-th key never falls.
fn prune(&mut self) {
if self.rows.len() <= self.cap {
return;
}
let mut keys: Vec<(i64, String)> = self
.rows
.iter()
.map(|(id, row)| (candidate_of(row), id.clone()))
.collect();
keys.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
for (_, id) in keys.drain(self.cap..) {
self.rows.remove(&id);
}
}
fn len(&self) -> usize {
self.candidates.len()
}
fn into_page(self, from: i64, size: i64) -> Vec<json::Value> {
let mut rows: Vec<json::Value> = self.rows.into_values().collect();
rows.sort_by(|a, b| {
candidate_of(b)
.cmp(&candidate_of(a))
.then_with(|| trace_id_of(a).cmp(&trace_id_of(b)))
});
rows.into_iter()
.skip(from.max(0) as usize)
.take(size.max(0) as usize)
.collect()
}
}
pub fn stream_response(ctx: TraceSearchContext, plan: TraceListPlan, span: Span) -> Response {
let trace_id = ctx.trace_id.clone();
let (tx, rx) = mpsc::channel::<Result<StreamResponses, infra::errors::Error>>(100);
@@ -139,6 +213,7 @@ async fn run(
let total_partitions = partitions.len();
let is_non_ts_order_by = plan.sort.by_duration;
let ranks_by_candidate = plan.sort.ranks_by_candidate(plan.mode);
let partitions_desc: Vec<[i64; 2]> = if !is_non_ts_order_by && plan.sort.ascending {
log::info!(
"[TRACES_STREAM trace_id {trace_id}] using {} time partitions, order=ASC",
@@ -150,7 +225,15 @@ async fn run(
"[TRACES_STREAM trace_id {trace_id}] using {} time partitions, order={}",
partitions.len(),
if is_non_ts_order_by {
format!("non-ts ({}), TopKHeap", plan.sort.sql())
format!(
"non-ts ({}), {}",
plan.sort.sql(plan.mode),
if ranks_by_candidate {
"candidate merge"
} else {
"TopKHeap"
}
)
} else {
"DESC".to_string()
}
@@ -165,7 +248,8 @@ async fn run(
};
let non_ts_col = "zo_sql_duration".to_string();
let non_ts_is_desc = !plan.sort.ascending;
let mut topk_heap: Option<TopKHeap> = if is_non_ts_order_by {
let mut candidate_merge = ranks_by_candidate.then(|| CandidateMerge::new(heap_k));
let mut topk_heap: Option<TopKHeap> = if is_non_ts_order_by && !ranks_by_candidate {
Some(TopKHeap::new(heap_k, &[(non_ts_col, non_ts_is_desc)]))
} else {
None
@@ -253,15 +337,20 @@ async fn run(
}
if is_non_ts_order_by {
for item in &deduped_hits {
if let Some(tid) = item.get("trace_id").and_then(|v| v.as_str())
&& !tid.is_empty()
{
seen_trace_ids.insert(tid.to_string());
if let Some(merge) = candidate_merge.as_mut() {
// Every partition reports the same trace again; the merge keeps the largest.
merge.push_hits(deduped_hits);
} else {
for item in &deduped_hits {
if let Some(tid) = item.get("trace_id").and_then(|v| v.as_str())
&& !tid.is_empty()
{
seen_trace_ids.insert(tid.to_string());
}
}
if let Some(ref mut heap) = topk_heap {
heap.push_hits(deduped_hits);
}
}
if let Some(ref mut heap) = topk_heap {
heap.push_hits(deduped_hits);
}
let progress = ((idx + 1) * 100) / total_partitions;
let _ = sender
@@ -344,10 +433,16 @@ async fn run(
}
if is_non_ts_order_by {
let deliverable_q1 = topk_heap
.take()
.map(|h| h.into_sorted_vec(from as usize))
.unwrap_or_default();
let (total, deliverable_q1) = match candidate_merge.take() {
Some(merge) => (merge.len(), merge.into_page(from, size)),
None => (
total_across_partitions,
topk_heap
.take()
.map(|h| h.into_sorted_vec(from as usize))
.unwrap_or_default(),
),
};
if deliverable_q1.is_empty() {
let _ = sender.send(Ok(StreamResponses::Done)).await;
@@ -377,7 +472,7 @@ async fn run(
for h in deliverable {
results.add_hit(h);
}
results.set_total(total_across_partitions);
results.set_total(total);
if !range_error.is_empty() {
results.function_error.push(range_error.clone());
}
@@ -461,10 +556,122 @@ fn select_timestamp_page(
hits.into_iter().skip(skip as usize).take(need).collect()
}
fn trace_id_of(hit: &json::Value) -> Option<&str> {
hit.get("trace_id").and_then(json::Value::as_str)
}
fn candidate_of(hit: &json::Value) -> i64 {
json::get_int_value(hit.get("zo_sql_candidate").unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(trace_id: &str, candidate: i64) -> json::Value {
json::json!({"trace_id": trace_id, "zo_sql_candidate": candidate})
}
fn ids(rows: &[json::Value]) -> Vec<&str> {
rows.iter().filter_map(trace_id_of).collect()
}
// The Q1 order `zo_sql_candidate DESC, trace_id ASC` (or ties cut the other way) then LIMIT.
fn sql_cut(mut hits: Vec<json::Value>, limit: usize, ids_ascending: bool) -> Vec<json::Value> {
hits.sort_by(|a, b| {
let by_id = trace_id_of(a).cmp(&trace_id_of(b));
candidate_of(b).cmp(&candidate_of(a)).then_with(|| {
if ids_ascending {
by_id
} else {
by_id.reverse()
}
})
});
hits.truncate(limit);
hits
}
fn page(
partitions: &[Vec<json::Value>],
from: i64,
size: i64,
ids_ascending: bool,
) -> Vec<String> {
let heap_k = (from + size) as usize;
let mut merge = CandidateMerge::new(heap_k);
for hits in partitions {
merge.push_hits(sql_cut(hits.clone(), heap_k, ids_ascending));
}
ids(&merge.into_page(from, size))
.into_iter()
.map(str::to_string)
.collect()
}
#[test]
fn candidate_merge_keeps_the_largest_row_per_trace_and_pages_by_candidate() {
// Example 3 of the spec: four partitions newest first, page size 2, trace A crosses all.
let partitions = [
vec![row("B", 4), row("E", 3)],
vec![row("A", 5), row("D", 4)],
vec![row("A", 5), row("C", 2)],
vec![row("A", 12)],
];
let mut merge = CandidateMerge::new(10);
for hits in partitions {
merge.push_hits(hits);
}
merge.push_hits(vec![row("A", 1), row("", 99)]);
assert_eq!(merge.len(), 5);
assert_eq!(merge.rows["A"], row("A", 12));
assert_eq!(ids(&merge.clone().into_page(0, 2)), ["A", "B"]);
assert_eq!(ids(&merge.clone().into_page(2, 2)), ["D", "E"]);
assert_eq!(ids(&merge.into_page(-1, 10)), ["A", "B", "D", "E", "C"]);
}
#[test]
fn candidate_merge_ranks_the_issue_example_by_its_root_span() {
// Issue 15507: A = 9 s root span in the older partition plus a 1 s span in the newer one.
let mut merge = CandidateMerge::new(1);
merge.push_hits(vec![row("B", 2_000_000), row("A", 1_000_000)]);
merge.push_hits(vec![row("A", 9_000_000)]);
assert_eq!(merge.len(), 2);
assert_eq!(ids(&merge.into_page(0, 1)), ["A"]);
}
#[test]
fn candidate_merge_keeps_at_most_cap_rows_and_readmits_a_pruned_id_that_grows() {
// from=1&size=1 keeps two rows: C is pruned, ignored while smaller, back once larger.
let mut merge = CandidateMerge::new(2);
merge.push_hits(vec![row("A", 5), row("B", 4), row("C", 3)]);
assert_eq!(merge.rows.len(), 2);
assert!(!merge.rows.contains_key("C"));
merge.push_hits(vec![row("C", 2)]);
assert!(!merge.rows.contains_key("C"));
merge.push_hits(vec![row("C", 10), row("B", 4)]);
assert_eq!(merge.rows.len(), 2);
assert_eq!(merge.len(), 3);
assert_eq!(ids(&merge.clone().into_page(0, 2)), ["C", "A"]);
assert_eq!(ids(&merge.into_page(1, 1)), ["A"]);
}
#[test]
fn the_trace_id_tiebreaker_keeps_tied_candidates_stable_across_pages() {
// Six traces tie at the cutoff; every page fetches `LIMIT from + size` per partition.
let partitions = vec![
vec![row("D", 5), row("C", 5), row("B", 5), row("A", 5)],
vec![row("F", 5), row("E", 5), row("G", 3)],
];
assert_eq!(page(&partitions, 0, 2, true), ["A", "B"]);
assert_eq!(page(&partitions, 2, 2, true), ["C", "D"]);
assert_eq!(page(&partitions, 4, 2, true), ["E", "F"]);
assert_eq!(page(&partitions, 6, 2, true), ["G"]);
// Without the tiebreaker a partition can cut the ties the other way: page 2 repeats page 1.
assert_eq!(page(&partitions, 0, 2, false), ["C", "D"]);
assert_eq!(page(&partitions, 2, 2, false), ["C", "D"]);
}
#[test]
fn timestamp_pagination_remembers_skipped_and_undelivered_ids() {
let hits = vec![
+4 -4
View File
@@ -186,7 +186,7 @@ pub async fn traces_write(
("stream_name" = String, Path, description = "Stream name"),
("filter" = Option<String>, Query, description = "filter, eg: a=b AND c=d"),
("from" = i64, Query, description = "from"),
("size" = i64, Query, description = "size"),
("size" = i64, Query, description = "page size; 0, negative, invalid or omitted uses ZO_QUERY_DEFAULT_LIMIT"),
("start_time" = i64, Query, description = "start time"),
("end_time" = i64, Query, description = "end time"),
("timeout" = Option<i64>, Query, description = "timeout, seconds"),
@@ -305,7 +305,7 @@ pub async fn get_latest_traces(
tag = "Traces",
operation_id = "GetLatestTracesStream",
summary = "Stream recent trace data",
description = "Streams the most recent trace data from a specific stream within a time range using HTTP/2 server-sent events. Results are returned progressively as each time partition is processed, reducing time-to-first-result for large time ranges.",
description = "Streams the most recent trace data from a specific stream within a time range using HTTP/2 server-sent events. Results are returned progressively as each time partition is processed, reducing time-to-first-result for large time ranges. With sort_by=duration, ranking across partitions is approximate; each page is sorted by full trace duration.",
security(
("Authorization"= [])
),
@@ -314,11 +314,11 @@ pub async fn get_latest_traces(
("stream_name" = String, Path, description = "Stream name"),
("filter" = Option<String>, Query, description = "filter, eg: a=b AND c=d"),
("from" = i64, Query, description = "from"),
("size" = i64, Query, description = "size"),
("size" = i64, Query, description = "page size; 0, negative, invalid or omitted uses ZO_QUERY_DEFAULT_LIMIT"),
("start_time" = i64, Query, description = "start time"),
("end_time" = i64, Query, description = "end time"),
("timeout" = Option<i64>, Query, description = "timeout, seconds"),
("sort_by" = Option<String>, Query, description = "sort by field: start_time, duration (default: start_time)"),
("sort_by" = Option<String>, Query, description = "sort by field: start_time, duration (default: start_time); see the endpoint description for the limits of the streamed duration sort"),
("sort_order" = Option<String>, Query, description = "sort order: asc, desc (default: desc)"),
),
responses(
+3
View File
@@ -169,6 +169,9 @@ impl super::Db for PostgresDb {
Err(e) => {
if let sqlx::Error::RowNotFound = e {
return Err(Error::from(DbError::KeyNotExists(key.to_string())));
} else if let sqlx::Error::Database(_) = e {
// stay typed: lc_messages localizes the text but not the SQLSTATE
return Err(Error::from(e));
} else {
return Err(Error::from(DbError::DBOperError(
e.to_string(),
+52 -4
View File
@@ -65,8 +65,7 @@ pub async fn get_db_schema_version() -> Result<u64, anyhow::Error> {
fn is_db_schema_version_missing(e: &errors::Error) -> bool {
match e {
errors::Error::DbError(errors::DbError::KeyNotExists(_)) => true,
// meta table not created yet: fresh install. the sqlite/postgres
// Db::get() wrap the sqlx error text into DBOperError
// meta table not created yet: sqlite Db::get() keeps only the error text
errors::Error::DbError(errors::DbError::DBOperError(msg, _)) => is_fresh_db_message(msg),
errors::Error::SqlxError(sqlx::Error::Database(e)) => {
e.code().as_deref() == Some("42P01") // postgres: undefined_table
@@ -172,6 +171,51 @@ pub async fn init() -> Result<(), anyhow::Error> {
mod schema_version_tests {
use super::*;
/// A postgres error whose message is localized while its SQLSTATE is not.
#[derive(Debug)]
struct PgError {
code: &'static str,
message: &'static str,
}
impl std::fmt::Display for PgError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message)
}
}
impl std::error::Error for PgError {}
impl sqlx::error::DatabaseError for PgError {
fn message(&self) -> &str {
self.message
}
fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
Some(self.code.into())
}
fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
self
}
fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
self
}
fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
self
}
fn kind(&self) -> sqlx::error::ErrorKind {
sqlx::error::ErrorKind::Other
}
}
fn pg_error(code: &'static str, message: &'static str) -> errors::Error {
errors::Error::SqlxError(sqlx::Error::Database(Box::new(PgError { code, message })))
}
#[test]
fn test_missing_version_key_is_fresh_install() {
let e = errors::Error::DbError(errors::DbError::KeyNotExists("/meta/kv/version".into()));
@@ -180,8 +224,7 @@ mod schema_version_tests {
#[test]
fn test_missing_meta_table_is_fresh_install() {
// sqlite and postgres Db::get() wrap the table-missing error into
// DBOperError with the sqlx message text
// table-missing errors that reach the classifier as text only
let e = errors::Error::DbError(errors::DbError::DBOperError(
"error returned from database: (code: 1) no such table: meta".to_string(),
"/meta/kv/version".to_string(),
@@ -192,6 +235,9 @@ mod schema_version_tests {
"/meta/kv/version".to_string(),
));
assert!(is_db_schema_version_missing(&e));
// postgres server with non-English lc_messages
let e = pg_error("42P01", "Relation »meta« existiert nicht");
assert!(is_db_schema_version_missing(&e));
}
#[test]
@@ -220,5 +266,7 @@ mod schema_version_tests {
"/meta/kv/version".to_string(),
));
assert!(!is_db_schema_version_missing(&e));
let e = pg_error("42501", "keine Berechtigung für Tabelle meta");
assert!(!is_db_schema_version_missing(&e));
}
}
@@ -0,0 +1,211 @@
"""Streamed trace list pagination: a size of 0, below 0 or omitted uses ZO_QUERY_DEFAULT_LIMIT [regression #15507].
The streamed endpoint used to stop before its first partition for size=-1 and size=0 and return no traces. Every
case runs through `traces/latest_stream` in all four sort combinations, because timestamp and duration sorts take
different paths through the partition loop. Offsets are checked on the descending sorts only.
"""
from __future__ import annotations
import logging
import secrets
import time
from collections.abc import Generator
from dataclasses import dataclass
from typing import Any
import pytest
from support.client import OpenObserveClient
from support.otlp_json import span, traces_request
from support.sse import read_sse_frames
from support.wait import wait_until
logger = logging.getLogger(__name__)
SEC_NS = 1_000_000_000
MS_NS = 1_000_000
SORTS = [("start_time", "desc"), ("start_time", "asc"), ("duration", "desc"), ("duration", "asc")]
DESC_SORTS = [("start_time", "desc"), ("duration", "desc")]
LETTERS = "ABCDEFGHIJKLMNO"
FEW_SERVICE = "e2e-15507-few"
MAIN_SERVICE = "e2e-15507-main"
@dataclass
class Seeded:
stream: str
window: tuple[int, int]
# Newest and longest first, so every descending sort lists them in this order.
ordered: list[str]
few: list[str]
def _trace(trace_id: str, start_ns: int, dur_ns: int, name: str) -> list[dict[str, Any]]:
# A covering root and a child that starts with it keep the trace in one partition, so its rank is exact.
root_id = secrets.token_hex(8)
root = span(name, trace_id=trace_id, span_id=root_id, time_unix_nano=start_ns, duration_nanos=dur_ns)
child = span(
f"{name}-child",
trace_id=trace_id,
span_id=secrets.token_hex(8),
time_unix_nano=start_ns,
duration_nanos=dur_ns // 2,
)
child["parentSpanId"] = root_id
return [root, child]
def _ingest(client: OpenObserveClient, stream: str, spans_by_service: dict[str, list[dict[str, Any]]]) -> None:
for service, spans in spans_by_service.items():
for i in range(0, len(spans), 500):
resp = client.post(
"v1/traces", json=traces_request(spans[i : i + 500], service=service), headers={"stream-name": stream}
)
assert resp.status_code == 200, f"Precondition: traces must ingest, got {resp.status_code}: {resp.text}"
def _count_spans(client: OpenObserveClient, stream: str, window: tuple[int, int]) -> int:
body = {
"query": {
"sql": f'SELECT count(*) AS count FROM "{stream}"',
"start_time": window[0],
"end_time": window[1],
"from": 0,
"size": 10,
}
}
resp = client.post("_search?type=traces", json=body)
if resp.status_code != 200:
return 0
hits = resp.json().get("hits") or []
return int(hits[0].get("count", 0)) if hits else 0
def _wait_for_spans(client: OpenObserveClient, stream: str, window: tuple[int, int], spans: int) -> None:
wait_until(
lambda: _count_spans(client, stream, window) >= spans,
timeout=90,
interval=2,
msg=f"all {spans} seeded spans must become searchable",
)
def _latest_stream(
client: OpenObserveClient, stream: str, window: tuple[int, int], sort: tuple[str, str], **params: Any
) -> list[dict[str, Any]]:
query = {
"start_time": window[0],
"end_time": window[1],
"sort_by": sort[0],
"sort_order": sort[1],
"use_cache": "false",
**{k: v for k, v in params.items() if v is not None},
}
resp = client.get(f"{stream}/traces/latest_stream", params=query, timeout=180)
assert resp.status_code == 200, f"latest_stream {query} failed: {resp.status_code} {resp.text[:300]}"
frames = read_sse_frames(resp)
errors = [data for event, data in frames if event == "error"]
assert not errors, f"latest_stream {query} streamed an error: {errors}"
return [hit for event, data in frames if event == "search_response_hits" for hit in data.get("hits") or []]
def _ids(hits: list[dict[str, Any]]) -> list[str]:
return [hit.get("trace_id") for hit in hits]
def _in_order(ids: list[str], sort: tuple[str, str]) -> list[str]:
return ids if sort[1] == "desc" else list(reversed(ids))
def _delete_stream(client: OpenObserveClient, stream: str) -> None:
try:
client.streams.delete(stream, type_="traces")
except Exception as e:
logger.warning("could not delete trace stream %s: %s", stream, e)
@pytest.fixture(scope="module")
def seeded(client: OpenObserveClient) -> Generator[Seeded, None, None]:
"""Fifteen traces A..O, 150 ms apart and 10 ms shorter each, A newest and longest; A, C and E form a smaller service."""
stream = f"e2e_15507_{secrets.token_hex(4)}"
base_ns = (time.time_ns() // SEC_NS - 600) * SEC_NS
ordered = [secrets.token_hex(16) for _ in LETTERS]
few = [ordered[0], ordered[2], ordered[4]]
spans: dict[str, list[dict[str, Any]]] = {MAIN_SERVICE: [], FEW_SERVICE: []}
for i, trace_id in enumerate(ordered):
start_ns = base_ns + (len(ordered) - 1 - i) * 150 * MS_NS
service = FEW_SERVICE if trace_id in few else MAIN_SERVICE
spans[service] += _trace(trace_id, start_ns, (len(ordered) + 1 - i) * 10 * MS_NS, f"op-{LETTERS[i]}")
window = (base_ns // 1000 - 1_000_000, base_ns // 1000 + 4_000_000)
_ingest(client, stream, spans)
_wait_for_spans(client, stream, window, 2 * len(ordered))
yield Seeded(stream=stream, window=window, ordered=ordered, few=few)
_delete_stream(client, stream)
@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}")
@pytest.mark.parametrize(
("size", "count"),
[(-1, 15), (0, 15), (-5, 15), (None, 15), (1, 1), (2, 2), (20, 15)],
)
def test_streamed_pages_follow_the_size_contract(
client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, count: int
):
"""#15507: a size of 0, below 0 or omitted returns every seeded trace, a positive size its own count."""
hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": 0})
expected = _in_order(seeded.ordered, sort)[:count]
got = _ids(hits)
logger.info("sort=%s size=%s -> %s traces", sort, size, len(got))
assert got == expected, f"sort={sort} size={size}: expected {len(expected)} traces in order, got {len(got)}"
@pytest.mark.parametrize("sort", DESC_SORTS, ids=lambda s: f"{s[0]}-{s[1]}")
@pytest.mark.parametrize(("size", "count"), [(-1, 13), (0, 13), (None, 13), (2, 2), (20, 13)])
def test_descending_offsets_skip_the_first_traces(
client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, count: int
):
"""#15507: from=2 skips the two newest or longest traces and returns the next page."""
hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": 2})
expected = seeded.ordered[2 : 2 + count]
assert _ids(hits) == expected, f"sort={sort} size={size} from=2: expected {len(expected)} traces from the third"
@pytest.mark.parametrize("sort", DESC_SORTS, ids=lambda s: f"{s[0]}-{s[1]}")
@pytest.mark.parametrize("size", [-1, 0, None, 2, 20])
@pytest.mark.parametrize("from_", [15, 40])
def test_an_offset_past_the_last_trace_returns_an_empty_page(
client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, from_: int
):
"""#15507: an offset beyond the matching traces ends the stream normally with no rows."""
hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": from_})
assert hits == [], f"sort={sort} size={size} from={from_}: expected no traces, got {len(hits)}"
@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}")
def test_filters_apply_to_every_size(client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str]):
"""#15507: a filter matching three traces returns those three for -1, 0, omitted and large sizes."""
few = _in_order(seeded.few, sort)
flt = f"service_name = '{FEW_SERVICE}'"
for size in [-1, 0, None, 20]:
hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, filter=flt)
assert _ids(hits) == few, f"sort={sort} size={size}: the filter must return its three traces, got {_ids(hits)}"
# The filtered page is re-aggregated over each whole trace: both spans and the root's duration.
for hit in hits:
assert hit.get("spans", [0])[0] == 2, f"filtered row lost its full span count: {hit}"
first = _latest_stream(client, seeded.stream, seeded.window, sort, size=1, filter=flt)
assert _ids(first) == few[:1], f"sort={sort}: size=1 must return the first filtered trace"
if sort in DESC_SORTS:
paged = _latest_stream(client, seeded.stream, seeded.window, sort, size=1, filter=flt, **{"from": 1})
assert _ids(paged) == few[1:2], f"sort={sort}: size=1 from=1 must return the second filtered trace"
@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}")
@pytest.mark.parametrize("size", [-1, 0, None, 2])
def test_a_filter_that_matches_nothing_returns_an_empty_page(
client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None
):
"""#15507: no matching trace ends the stream normally with no rows for every size."""
flt = "service_name = 'e2e-15507-nobody'"
hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, filter=flt)
assert hits == [], f"sort={sort} size={size}: a filter that matches nothing must return no traces"
+12 -2
View File
@@ -6798,9 +6798,19 @@ mod tests {
async fn e2e_config() {
let auth = setup();
let app = init_test_router();
let headers = auth_headers(auth);
let (status, _body) = make_request(&app, Method::GET, "/config", Some(headers), None).await;
let (status, body) = make_request(&app, Method::GET, "/config", None, None).await;
assert!(status.is_success());
let bootstrap: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(bootstrap.get("rum").is_none(), "{bootstrap}");
assert!(bootstrap.get("commit_hash").is_some(), "{bootstrap}");
let headers = auth_headers(auth);
let (status, body) =
make_request(&app, Method::GET, "/api/e2e/config", Some(headers), None).await;
assert!(status.is_success());
let full: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(full["rum"]["version"], config::VERSION, "{full}");
assert!(full["rum"].get("client_token").is_some(), "{full}");
}
// Helper function to create pipeline via API
+7 -2
View File
@@ -58,6 +58,7 @@
"dashboard-series-color-multiwindow.spec.js",
"dashboard-config-advanced.spec.js",
"dashboard-config-axis.spec.js",
"dashboard-stacked-axis-bounds.spec.js",
"dashboard-config-gauge-maps.spec.js",
"dashboard-config-general.spec.js",
"dashboard-config-heatmap.spec.js",
@@ -358,7 +359,9 @@
"logsRefreshCacheDropdown.spec.js",
"logs-highlighting.spec.js",
"logs-search-around-multistream.spec.js",
"logs-default-columns.spec.js"
"logs-default-columns.spec.js",
"logsResultsToolbar.spec.js",
"logsSavedFunctionOverwrite.spec.js"
],
"disabled": [
{
@@ -386,7 +389,9 @@
"logsActionReasons.spec.js",
"logsSearchCancelled.spec.js",
"logsToolbarPins.spec.js",
"logs-query-editor-keyboard.spec.js"
"logs-query-editor-keyboard.spec.js",
"logsCrossLinks.spec.js",
"logsFieldValuePaging.spec.js"
],
"disabled": [
{
@@ -660,17 +660,53 @@ export default class DashboardactionPage {
await this.page.waitForTimeout(1000);
}
// Legend is canvas-painted, so read series names via `_vnode` (`__vueParentComponent` is dev-only).
async getChartSeriesNames(scopeSelector = '[data-test="chart-renderer"]') {
const names = (await this.getChartSeries(scopeSelector)).map((s) => s.name).filter(Boolean);
return [...new Set(names)];
}
async getChartSeries(scopeSelector = '[data-test="chart-renderer"]') {
const charts = await this.getChartOptionSnapshots(scopeSelector);
return charts.flatMap((chart) =>
chart.series.map((s) => ({
name: s.name,
values: s.points.map((point) =>
Array.isArray(point) ? point[point.length - 1] : point,
),
})),
);
}
/**
* Value-axis bounds and stacked series of the first rendered chart, read from the live ECharts option.
* @param {"yAxis"|"xAxis"} valueAxis - the axis that carries values (xAxis for h-stacked)
* @param {string} scopeSelector
* @returns {Promise<{min: number|null, max: number|null, series: Array<{name: string, stack: string|null, points: Array}>}|null>}
*/
async getChartValueAxisFit(valueAxis = "yAxis", scopeSelector = '[data-test="chart-renderer"]') {
const [chart] = await this.getChartOptionSnapshots(scopeSelector);
if (!chart) return null;
const bounds = chart[valueAxis]?.[0] ?? {};
return { min: bounds.min ?? null, max: bounds.max ?? null, series: chart.series };
}
// Legend and axes are canvas-painted, so read the live option via `_vnode` (`__vueParentComponent` is dev-only).
async getChartOptionSnapshots(scopeSelector = '[data-test="chart-renderer"]') {
return this.page.evaluate((scopeSel) => {
const scopes = Array.from(document.querySelectorAll(scopeSel));
const series = [];
const charts = [];
const visited = new Set();
const numberOrNull = (value) => (typeof value === "number" ? value : null);
const axesOf = (axis) =>
(Array.isArray(axis) ? axis : axis ? [axis] : []).map((a) => ({
type: a?.type ?? null,
min: numberOrNull(a?.min),
max: numberOrNull(a?.max),
}));
const pointOf = (point) => {
if (Array.isArray(point)) return point.map((v) => (v instanceof Date ? v.getTime() : v));
return point !== null && typeof point === "object" ? point.value : point;
};
const walk = (node, depth) => {
if (!node || typeof node !== "object" || depth > 400 || visited.has(node)) return;
visited.add(node);
@@ -691,12 +727,16 @@ export default class DashboardactionPage {
scopes.some((scope) => scope.contains(host))
) {
try {
for (const s of chart.getOption()?.series ?? []) {
const values = (s?.data ?? []).map((point) =>
Array.isArray(point) ? point[point.length - 1] : (point?.value ?? point),
);
series.push({ name: s?.name ? String(s.name) : "", values });
}
const option = chart.getOption() ?? {};
charts.push({
xAxis: axesOf(option.xAxis),
yAxis: axesOf(option.yAxis),
series: (option.series ?? []).map((s) => ({
name: s?.name ? String(s.name) : "",
stack: s?.stack === undefined || s?.stack === null ? null : String(s.stack),
points: (s?.data ?? []).map(pointOf),
})),
});
} catch {
// A disposed instance throws; it draws nothing anyway.
}
@@ -707,7 +747,7 @@ export default class DashboardactionPage {
if (Array.isArray(node.children)) for (const c of node.children) walk(c, depth + 1);
};
walk(document.querySelector("#app")?._vnode, 0);
return series;
return charts;
}, scopeSelector);
}
@@ -43,6 +43,8 @@ export default class DashboardPanelConfigs {
);
this.yAxisMin = page.locator('[data-test="dashboard-config-y_axis_min"]');
this.yAxisMax = page.locator('[data-test="dashboard-config-y_axis_max"]');
this.yAxisMinField = this.yAxisMin.locator('[data-test$="-field"]');
this.yAxisMaxField = this.yAxisMax.locator('[data-test$="-field"]');
this.valuePosition = page.locator(
'[data-test="dashboard-config-label-position"]'
);
@@ -0,0 +1,102 @@
// 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/>.
// Page object for org-level cross-links as they reach the log-detail drawer.
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
export class LogsCrossLinkPage {
constructor(page) {
this.page = page;
this.detailDialog = '[data-test="logs-search-result-detail-dialog"]';
this.detailTableTab = '[data-test="log-detail-table-tab"]';
this.detailTableContent = '[data-test="log-detail-table-content"]';
this.fieldMenuTrigger = (field) => `[data-test="log-details-include-exclude-field-btn-${field}"]`;
this.fieldValue = (field) => `[data-test="log-detail-${field}-value"]`;
this.crossLinkItem = (name) => `[data-test="log-details-cross-link-${name}"]`;
this.anyCrossLinkItem = '[data-test^="log-details-cross-link-"]';
}
// ===== ORG SETTINGS (POST /settings replaces the whole cross_links array) =====
async readOrgCrossLinks() {
const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`;
const response = await this.page.request.get(url, { headers: getAuthHeaders() });
expect(response.status(), 'could not read org settings').toBe(200);
const body = await response.json();
return body?.data?.cross_links ?? [];
}
async writeOrgCrossLinks(links) {
const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`;
const response = await this.page.request.post(url, {
headers: getAuthHeaders(),
data: { cross_links: links },
});
expect(response.status(), 'could not write org settings').toBe(200);
testLogger.debug('Org cross_links written', { count: links.length });
}
// ===== DRAWER =====
async openFieldTableTab() {
await this.page.locator(this.detailDialog).locator(this.detailTableTab).click();
await expect(this.page.locator(this.detailTableContent)).toBeVisible({ timeout: 15000 });
}
async openFieldMenu(field) {
const trigger = this.page.locator(this.fieldMenuTrigger(field)).first();
await trigger.scrollIntoViewIfNeeded().catch(() => {});
await trigger.click();
}
async expectCrossLinkOffered(name) {
await expect(this.page.locator(this.crossLinkItem(name))).toBeVisible({ timeout: 10000 });
}
async readFieldValue(field) {
const cell = this.page.locator(this.fieldValue(field)).first();
await expect(cell).toBeVisible({ timeout: 15000 });
return (await cell.innerText()).trim();
}
/**
* Clicks the cross-link item and returns the page window.open actually created.
* The item passes `noopener`, so the new tab arrives on the context, not as a popup
* of this page.
*/
async openCrossLinkAndReadUrl(name) {
const context = this.page.context();
const [opened] = await Promise.all([
context.waitForEvent('page', { timeout: 20000 }),
this.page.locator(this.crossLinkItem(name)).first().click(),
]);
await expect
.poll(() => opened.url(), {
timeout: 15000,
message: 'the opened tab never committed a URL',
})
.not.toBe('about:blank');
const url = opened.url();
await opened.close();
testLogger.info('Cross-link opened a real tab', { url });
return url;
}
}
@@ -0,0 +1,106 @@
// 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 { expect } from '@playwright/test';
export class LogsFieldValuesPage {
constructor(page) {
this.page = page;
this.valuesPanel = '[data-test="field-values-panel-container"]';
// OSearchInput renders no data-test of its own, so the wrapper class is the hook.
this.valueSearchInput = `${this.valuesPanel} .value-search-input-wrap input`;
}
expandButton(field) {
return `[data-test="log-search-expand-${field}-field-btn"]`;
}
loadMoreButton(field) {
return `[data-test="log-search-subfield-load-more-${field}"]`;
}
valueRowPrefix(field) {
return `logs-search-subfield-add-${field}-`;
}
async openLogs(org, stream) {
const url =
`/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` +
'&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true';
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
}
async expandField(field) {
const button = this.page.locator(this.expandButton(field));
await button.waitFor({ state: 'visible', timeout: 60000 });
await button.click();
await this.page
.locator(this.valuesPanel)
.first()
.waitFor({ state: 'visible', timeout: 30000 });
}
/** The distinct value keys currently rendered for `field`, in display order. */
async valueKeys(field) {
const prefix = this.valueRowPrefix(field);
return this.page
.locator(`[data-test^="${prefix}"]`)
.evaluateAll(
(elements, len) => elements.map((el) => el.getAttribute('data-test').slice(len)),
prefix.length,
);
}
async waitForValues(field, timeout = 60000) {
await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(0);
return this.valueKeys(field);
}
async expectLoadMoreVisible(field) {
await expect(this.page.locator(this.loadMoreButton(field))).toBeVisible({ timeout: 30000 });
}
async clickLoadMore(field) {
const button = this.page.locator(this.loadMoreButton(field));
await button.waitFor({ state: 'visible', timeout: 30000 });
await button.click();
}
async waitForMoreThan(field, count, timeout = 60000) {
await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(count);
return this.valueKeys(field);
}
async searchValues(term) {
const input = this.page.locator(this.valueSearchInput).first();
await input.waitFor({ state: 'visible', timeout: 30000 });
await input.fill(term);
}
/** Polls until every rendered key contains `term` and at least one is rendered. */
async waitForValuesMatching(field, term, timeout = 60000) {
await expect
.poll(
async () => {
const keys = await this.valueKeys(field);
return keys.length > 0 && keys.every((key) => key.includes(term));
},
{ timeout },
)
.toBe(true);
return this.valueKeys(field);
}
}
@@ -0,0 +1,65 @@
// 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/>.
// Page object for the results-header actions and the overflow menu they collapse into
// below SearchResult.vue's measured 700px container width.
import { expect } from '@playwright/test';
const testLogger = require('../../playwright-tests/utils/test-logger.js');
export class LogsResultsToolbarPage {
constructor(page) {
this.page = page;
this.wrapContentKey = 'wrapContent';
this.inlineWrapButton = '[data-test="logs-search-result-wrap-table-content-btn"]';
this.overflowMenuButton = '[data-test="logs-result-actions-menu-btn"]';
this.overflowWrapItem = '[data-test="logs-result-wrap-menu-item"]';
this.overflowRefreshItem = '[data-test="logs-result-refresh-menu-item"]';
}
/**
* Resizes, then waits for the header to settle: containerWidth comes from a
* ResizeObserver, so reading the toolbar at once races the recompute.
*/
async setViewportWidthAndSettle(width, { expectOverflow }) {
await this.page.setViewportSize({ width, height: 1024 });
await this.page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))),
),
);
const appears = expectOverflow ? this.overflowMenuButton : this.inlineWrapButton;
const disappears = expectOverflow ? this.inlineWrapButton : this.overflowMenuButton;
await expect(this.page.locator(appears)).toBeVisible({ timeout: 20000 });
await expect(this.page.locator(disappears)).toHaveCount(0);
testLogger.debug('Results header settled', { width, expectOverflow });
}
async openOverflowMenu() {
await this.page.locator(this.overflowMenuButton).click();
await expect(this.page.locator(this.overflowRefreshItem)).toBeVisible({ timeout: 10000 });
}
async clickOverflowWrapItem() {
await this.page.locator(this.overflowWrapItem).click();
await expect(this.page.locator(this.overflowRefreshItem)).toHaveCount(0);
}
async readStoredWrapContent() {
return await this.page.evaluate((key) => localStorage.getItem(key), this.wrapContentKey);
}
}
@@ -0,0 +1,102 @@
// 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 { expect } from '@playwright/test';
export class LogsSavedFunctionPage {
constructor(page) {
this.page = page;
this.functionEditor = '[data-test="logs-vrl-function-editor"]';
this.saveFunctionButton = '[data-test="logs-search-bar-save-function-btn"]';
this.functionDropdown = '[data-test="logs-search-bar-function-dropdown"]';
this.savedFunctionDialog = '[data-test="search-bar-store-state-saved-function-dialog"]';
this.updateConfirmDialog = '[data-test="search-bar-function-update-confirm-dialog"]';
this.dialogPrimaryButton = '[data-test="o-dialog-primary-btn"]';
this.nameInput = '[data-test="saved-function-name-input-field"]';
this.updateModeButton = '[data-test="saved-function-action-toggle"] button[value="update"]';
this.nameSelectTrigger = '[data-test="saved-function-name-select-trigger"]';
}
savedFunctionItem(name) {
return `[data-test="logs-search-saved-function-${name}"]`;
}
nameSelectOption(name) {
return `[data-test="saved-function-name-select-option"][data-test-value="${name}"]`;
}
async openLogs(org, stream) {
const url =
`/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` +
'&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true&fn_editor=true';
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
await this.page
.locator(`${this.functionEditor} .monaco-editor`)
.first()
.waitFor({ state: 'visible', timeout: 60000 });
}
async setFunctionBody(text) {
const editor = this.page.locator(`${this.functionEditor} .monaco-editor`).first();
await editor.click();
await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
// Typed, not filled: SearchBar reads tempFunctionContent from Monaco's change events, which a direct value write never fires.
const textarea = this.page.locator(`${this.functionEditor} .monaco-editor textarea`);
await textarea.pressSequentially(text);
await expect.poll(() => this.functionBody(), { timeout: 15000 }).toContain(text);
// CodeQueryEditor holds the edit for its 500ms debounce; blur flushes it to tempFunctionContent, which the save dialog gates on.
await textarea.blur();
}
async functionBody() {
const text = await this.page.locator(`${this.functionEditor} .view-lines`).first().innerText();
return text.replace(/\u00a0/g, ' ').trim();
}
async openSaveDialog() {
await this.page.locator(this.saveFunctionButton).click();
await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'visible', timeout: 30000 });
}
async submitDialog(dialog) {
await this.page.locator(`${dialog} ${this.dialogPrimaryButton}`).click();
}
async createFunction(name) {
await this.openSaveDialog();
await this.page.locator(this.nameInput).fill(name);
await this.submitDialog(this.savedFunctionDialog);
await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 });
}
async overwriteFunction(name) {
await this.openSaveDialog();
await this.page.locator(this.updateModeButton).click();
await this.page.locator(this.nameSelectTrigger).click();
await this.page.locator(this.nameSelectOption(name)).click();
await this.submitDialog(this.savedFunctionDialog);
await this.page.locator(this.updateConfirmDialog).waitFor({ state: 'visible', timeout: 30000 });
await this.submitDialog(this.updateConfirmDialog);
await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 });
}
async applySavedFunction(name) {
await this.page.locator(this.functionDropdown).click();
await this.page.locator(this.savedFunctionItem(name)).click();
}
}
@@ -0,0 +1,213 @@
const {
test,
expect,
navigateToBase,
} = require("../utils/enhanced-baseFixtures.js");
import PageManager from "../../pages/page-manager";
import { ingestion } from "./utils/dashIngestion.js";
import { cleanupTestDashboard } from "./utils/dashCreation.js";
import {
generateDashboardName,
setupStackedPanelWithConfig,
setupBarPanelWithBreakdownAndConfig,
setupPromQLStackedPanelWithConfig,
reopenPanelConfig,
} from "./utils/configPanelHelpers.js";
import { stackExtent, tallestPoint, waitForValueAxisFit } from "./utils/stackedAxisHelpers.js";
const testLogger = require("../utils/test-logger.js");
const { ensureMetricsIngested } = require("../utils/shared-metrics-setup.js");
// Below any real stack of the ingested counts, so a kept bound would visibly clip the chart.
const CLIPPING_MAX = "1";
const ROOMY_MAX = "100000000";
const maxApplied = (fit) => fit.max !== null;
const minApplied = (fit) => fit.min !== null;
test.describe.configure({ mode: "parallel" });
test.describe("Stacked panels — user y-axis bounds never clip a stack (SQL)", () => {
test.beforeEach(async ({ page }) => {
await navigateToBase(page);
await ingestion(page);
});
test(
"stacked: y max below the tallest stack widens to the stack sum; saved value stays as typed",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P0", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupStackedPanelWithConfig(page, pm, dashboardName);
await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX);
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
const { hi } = stackExtent(fit.series);
testLogger.info("Stacked fit in editor", { max: fit.max, tallestStack: hi });
expect(hi).toBeGreaterThan(Number(CLIPPING_MAX));
expect(fit.max).toBeCloseTo(hi, 6);
await pm.dashboardPanelActions.savePanel();
const savedFit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
expect(savedFit.max).toBeCloseTo(stackExtent(savedFit.series).hi, 6);
testLogger.info("Saved dashboard panel keeps the full stacks", { max: savedFit.max });
await reopenPanelConfig(page, pm);
await expect(pm.dashboardPanelConfigs.yAxisMaxField).toHaveValue(CLIPPING_MAX);
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
test(
"stacked: y max above every stack is kept as set",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupStackedPanelWithConfig(page, pm, dashboardName);
await pm.dashboardPanelConfigs.Y_AxisMax(ROOMY_MAX);
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
const { hi } = stackExtent(fit.series);
testLogger.info("Roomy max", { max: fit.max, tallestStack: hi });
expect(hi).toBeLessThan(Number(ROOMY_MAX));
expect(fit.max).toBe(Number(ROOMY_MAX));
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
test(
"stacked: y min above zero widens down to the bar baseline; an unset max stays automatic",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupStackedPanelWithConfig(page, pm, dashboardName);
await pm.dashboardPanelConfigs.Y_AxisMin("5");
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { ready: minApplied });
testLogger.info("Stacked min fit", { min: fit.min, max: fit.max });
expect(fit.min).toBe(0);
expect(fit.max).toBeNull();
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
test(
"h-stacked: the value (x) axis max widens to the stack sum",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupStackedPanelWithConfig(page, pm, dashboardName, { chartType: "h-stacked" });
await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX);
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { valueAxis: "xAxis", ready: maxApplied });
const { hi } = stackExtent(fit.series);
testLogger.info("h-stacked fit", { max: fit.max, tallestStack: hi });
expect(hi).toBeGreaterThan(Number(CLIPPING_MAX));
expect(fit.max).toBeCloseTo(hi, 6);
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
test(
"area-stacked: y max below the tallest stack widens to the stack sum",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupStackedPanelWithConfig(page, pm, dashboardName, { chartType: "area-stacked" });
await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX);
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
const { hi } = stackExtent(fit.series);
testLogger.info("area-stacked fit", { max: fit.max, tallestStack: hi });
expect(hi).toBeGreaterThan(Number(CLIPPING_MAX));
expect(fit.max).toBeCloseTo(hi, 6);
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
test(
"bar (not stacked): y max widens only to the tallest single bar, never to a stack sum",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P2", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupBarPanelWithBreakdownAndConfig(page, pm, dashboardName);
await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX);
await pm.dashboardPanelActions.applyDashboardBtn();
await pm.dashboardPanelActions.waitForChartToRender();
const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
const tallestBar = tallestPoint(fit.series);
// Summing every series per bucket is what the bound would be if bars were wrongly treated as one stack.
const asOneStack = stackExtent(fit.series.map((s) => ({ ...s, stack: "all" }))).hi;
testLogger.info("Bar fits its tallest bar", { max: fit.max, tallestBar, asOneStack });
expect(tallestBar).toBeGreaterThan(Number(CLIPPING_MAX));
expect(asOneStack).toBeGreaterThan(tallestBar);
expect(fit.max).toBe(tallestBar);
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
});
test.describe("Stacked panels — user y-axis bounds never clip a stack (PromQL)", () => {
test.beforeAll(async () => {
await ensureMetricsIngested();
});
test.beforeEach(async ({ page }) => {
await navigateToBase(page);
});
test(
"PromQL stacked: y max below the tallest stack widens to the stack sum",
{ tag: ["@dashboardStackedAxisBounds", "@dashboards", "@promql", "@P1", "@all"] },
async ({ page }) => {
const pm = new PageManager(page);
const dashboardName = generateDashboardName();
await setupPromQLStackedPanelWithConfig(page, pm, dashboardName);
// cpu_usage is a percentage, so a sub-1 max always cuts into its stack.
await pm.dashboardPanelConfigs.Y_AxisMax("0.001");
await pm.dashboardPanelActions.applyAndWaitForPromQLQuery();
const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied });
const { hi } = stackExtent(fit.series);
testLogger.info("PromQL stacked fit", { max: fit.max, tallestStack: hi });
expect(hi).toBeGreaterThan(0.001);
expect(fit.max).toBeCloseTo(hi, 6);
await pm.dashboardPanelActions.savePanel();
await cleanupTestDashboard(page, pm, dashboardName);
},
);
});
@@ -168,6 +168,22 @@ export async function setupBarPanelWithBreakdownAndConfig(page, pm, dashboardNam
testLogger.info("Bar panel with breakdown and config ready", { dashboardName, panelName });
}
/**
* Stacked-family panel (stacked / h-stacked / area-stacked) broken down by host so each bucket stacks several series — config sidebar opened and ready.
*/
export async function setupStackedPanelWithConfig(page, pm, dashboardName, {
chartType = "stacked",
panelName = "Test Panel",
} = {}) {
await buildPanel(page, pm, dashboardName, {
chartType,
panelName,
breakdownField: "kubernetes_host",
});
await pm.dashboardPanelConfigs.openConfigPanel();
testLogger.info("Stacked panel with breakdown and config ready", { chartType, dashboardName, panelName });
}
/**
* Line chart panel — config sidebar opened and ready.
*/
@@ -396,6 +412,16 @@ export async function setupPromQLPanelWithConfig(page, pm, dashboardName, panelN
testLogger.info("PromQL line panel with config ready", { dashboardName, panelName });
}
/**
* PromQL stacked panel over the seeded cpu_usage metric — config sidebar opened and ready.
* Caller's test.beforeAll must call ensureMetricsIngested().
*/
export async function setupPromQLStackedPanelWithConfig(page, pm, dashboardName, panelName = "Test Panel") {
await buildPromQLPanel(page, pm, dashboardName, { chartType: "stacked", panelName, query: "cpu_usage" });
await pm.dashboardPanelConfigs.openConfigPanel();
testLogger.info("PromQL stacked panel with config ready", { dashboardName, panelName });
}
/**
* PromQL heatmap panel — config sidebar opened and ready.
* Caller's test.beforeAll must call ensureMetricsIngested().
@@ -0,0 +1,76 @@
/**
* Helpers for asserting how stacked panels fit a user-set value-axis min/max to the rendered stacks.
*/
/**
* Tallest positive and deepest negative stack end across every bucket; positives and negatives stack apart.
*
* @param {Array<{name: string, stack: string|null, points: Array}>} series
* @returns {{hi: number, lo: number}} stack extent, anchored at 0 like a bar baseline
*/
export function stackExtent(series) {
const sums = new Map();
let hi = 0;
let lo = 0;
series.forEach((s, seriesIndex) => {
// Unnamed series carry mark lines and annotations, not stacked data.
if (!s.name) return;
const group = s.stack ?? `series:${seriesIndex}`;
s.points.forEach((point, index) => {
// A time-axis point is [time, value]; a category point sits at its category's index.
const [bucket, raw] = Array.isArray(point) ? [point[0], point[1]] : [index, point];
const value = Number(raw);
if (raw === null || raw === undefined || raw === "" || !Number.isFinite(value)) return;
const key = `${group}|${value < 0 ? "-" : "+"}|${bucket}`;
const sum = (sums.get(key) ?? 0) + value;
sums.set(key, sum);
if (value < 0) lo = Math.min(lo, sum);
else hi = Math.max(hi, sum);
});
});
return { hi, lo };
}
/**
* Largest single plotted value across all named series, ignoring how they stack.
*
* @param {Array<{name: string, points: Array}>} series
* @returns {number}
*/
export function tallestPoint(series) {
return Math.max(
0,
...series
.filter((s) => s.name)
.flatMap((s) => s.points.map((p) => Number(Array.isArray(p) ? p[1] : p)))
.filter(Number.isFinite),
);
}
/**
* Polls the rendered chart until its series carry data and `ready` accepts the fit, then returns that fit.
*
* @param {object} pm - PageManager instance
* @param {Function} expect - Playwright expect
* @param {object} [options]
* @param {"yAxis"|"xAxis"} [options.valueAxis]
* @param {(fit: object) => boolean} [options.ready] - e.g. wait until the applied max reached the option
* @param {string} [options.scopeSelector]
*/
export async function waitForValueAxisFit(pm, expect, {
valueAxis = "yAxis",
ready = () => true,
scopeSelector,
} = {}) {
let fit = null;
await expect
.poll(
async () => {
fit = await pm.dashboardPanelActions.getChartValueAxisFit(valueAxis, scopeSelector);
return Boolean(fit?.series.some((s) => s.name && s.points.length > 0) && ready(fit));
},
{ timeout: 30000, intervals: [500, 1000, 2000] },
)
.toBe(true);
return fit;
}
@@ -0,0 +1,115 @@
// 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/>.
// Cross-links reach the drawer only if the server returns stream_links/org_links for
// the queried fields, which no unit test can stand in for.
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const logData = require('../../fixtures/log.json');
const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
const { LogsCrossLinkPage } = require('../../pages/logsPages/logsCrossLinkPage.js');
const STREAM = 'e2e_automate';
const LINKED_FIELD = 'kubernetes_namespace_name';
// The org-level cross_links array is shared, so no two workers may hold a snapshot of it.
test.describe.configure({ mode: 'serial' });
test.describe('Logs cross-links', () => {
let pm;
let crossLinkPage;
let linkName;
let savedCrossLinks;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
pm = new PageManager(page);
crossLinkPage = new LogsCrossLinkPage(page);
const configResponse = await page.request.get(
`${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/config`,
{ headers: getAuthHeaders() },
);
const zoConfig = await configResponse.json();
// Server env, not org settings: a test cannot turn this on, so off means misconfigured.
expect(
zoConfig?.enable_cross_linking,
'ZO_ENABLE_CROSS_LINKING is off on this environment, so no cross-link is ever offered. ' +
'It is set true by .github/workflows/playwright.yml:63 and ' +
'playwright_bugfix_regression.yml:43 — set the env var on the environment under test. ' +
'Do not skip or delete this spec.',
).toBeTruthy();
linkName = `e2e_crosslink_${Date.now()}`;
savedCrossLinks = await crossLinkPage.readOrgCrossLinks();
await crossLinkPage.writeOrgCrossLinks([
...savedCrossLinks,
{
name: linkName,
// A cheap same-origin target: the assertion is the resolved URL, not what it renders.
url:
`${process.env.ZO_BASE_URL}/config?xl_field=\${field.__name}` +
'&xl_value=${field.__value}&xl_from=${start_time}&xl_to=${end_time}&xl_query=${query}',
fields: [{ name: LINKED_FIELD }],
},
]);
await ingestion(page);
await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`);
await pm.logsPage.selectStream(STREAM);
await pm.logsPage.clickDateTimeButton();
await pm.logsPage.clickRelative15MinButton();
await pm.logsPage.clickSearchBarRefreshButton();
await pm.logsPage.expectResultsGridSettledWithRows();
await pm.logsPage.openLogDetailSidebar();
await crossLinkPage.openFieldTableTab();
});
test.afterEach(async () => {
// Restore the exact array that was there; a POST replaces cross_links wholesale.
if (savedCrossLinks) await crossLinkPage.writeOrgCrossLinks(savedCrossLinks);
});
test('a configured cross-link opens the resolved URL in a real tab', {
tag: ['@crossLinks', '@logs', '@P1', '@all'],
}, async () => {
const fieldValue = await crossLinkPage.readFieldValue(LINKED_FIELD);
expect(fieldValue, 'the linked field rendered no value to substitute').not.toBe('');
await crossLinkPage.openFieldMenu(LINKED_FIELD);
await crossLinkPage.expectCrossLinkOffered(linkName);
const openedUrl = await crossLinkPage.openCrossLinkAndReadUrl(linkName);
// A literal space would mean the SQL was spliced in unencoded and the href is invalid.
expect(openedUrl, 'the resolved URL was not percent-encoded').not.toMatch(/\s/);
const params = new URL(openedUrl).searchParams;
expect(params.get('xl_field')).toBe(LINKED_FIELD);
expect(params.get('xl_value')).toBe(fieldValue);
expect(params.get('xl_query')).toContain(STREAM);
const from = Number(params.get('xl_from'));
const to = Number(params.get('xl_to'));
expect(String(from), 'start_time was not substituted as microseconds').toMatch(/^\d{16}$/);
expect(String(to), 'end_time was not substituted as microseconds').toMatch(/^\d{16}$/);
expect(from).toBeLessThan(to);
testLogger.info('Cross-link resolved and opened with the row value and search window');
});
});
@@ -0,0 +1,115 @@
// 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/>.
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js');
const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
const { LogsFieldValuesPage } = require('../../pages/logsPages/logsFieldValuesPage.js');
const ORG = getOrgIdentifier();
const STREAM = `fvpaging${Date.now()}`;
const FIELD = 'host';
// Matches db-10..db-12 only, which no page of this fixture ever rendered, so the panel's local cache filter cannot produce a hit.
const SEARCH_TERM = 'db-1';
function apiBase() {
const url = process.env.ZO_BASE_URL;
return url.endsWith('/') ? url.slice(0, -1) : url;
}
// Distinct per-value counts pin the backend rank order, so page 2 is the next values down and never a reshuffle of page 1.
function fixtureRows() {
const now = Date.now() * 1000;
const rows = [];
let offset = 0;
const push = (host, count) => {
for (let i = 0; i < count; i++) {
rows.push({ _timestamp: now - 5 * 60e6 + offset++ * 1000, host, body: `probe ${host} ${i}` });
}
};
for (let i = 1; i <= 12; i++) push(`web-${String(i).padStart(2, '0')}`, 25 - i);
for (let i = 1; i <= 12; i++) push(`db-${String(i).padStart(2, '0')}`, 13 - i);
return rows;
}
const FIXTURE = fixtureRows();
// beforeAll/afterAll run per worker, and this file shares one ingested stream.
test.describe.configure({ mode: 'serial' });
test.describe('Logs field-value paging and in-field search', () => {
let fieldValues;
test.beforeAll(async ({ request }) => {
await ingestRows(request, ORG, STREAM, FIXTURE);
await waitForSearchable(request, ORG, STREAM, FIXTURE.length);
});
test.afterAll(async ({ request }) => {
const response = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, {
headers: getAuthHeaders(),
});
testLogger.info('Deleted the stream this file created', {
stream: STREAM,
status: response.status(),
});
});
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
fieldValues = new LogsFieldValuesPage(page);
await fieldValues.openLogs(ORG, STREAM);
});
test('"load more" fetches additional distinct values from the backend', {
tag: ['@fieldValuePaging', '@all', '@logs'],
}, async () => {
await fieldValues.expandField(FIELD);
const firstPage = await fieldValues.waitForValues(FIELD);
await fieldValues.expectLoadMoreVisible(FIELD);
await fieldValues.clickLoadMore(FIELD);
const afterLoadMore = await fieldValues.waitForMoreThan(FIELD, firstPage.length);
// A backend ignoring the larger `size` would answer the same page and leave the list unchanged, so the delta is the whole assertion.
const added = afterLoadMore.filter((key) => !firstPage.includes(key));
expect(added.length).toBeGreaterThan(0);
expect(new Set(afterLoadMore).size).toBe(afterLoadMore.length);
for (const key of firstPage) expect(afterLoadMore).toContain(key);
testLogger.info('Load more appended distinct values', {
firstPage: firstPage.length,
added: added.length,
});
});
test('in-field value search returns values the first page never held', {
tag: ['@fieldValuePaging', '@all', '@logs'],
}, async () => {
await fieldValues.expandField(FIELD);
const firstPage = await fieldValues.waitForValues(FIELD);
expect(firstPage.some((key) => key.includes(SEARCH_TERM))).toBe(false);
await fieldValues.searchValues(SEARCH_TERM);
const narrowed = await fieldValues.waitForValuesMatching(FIELD, SEARCH_TERM);
expect(narrowed.length).toBeGreaterThan(0);
for (const key of narrowed) expect(firstPage).not.toContain(key);
testLogger.info('In-field search narrowed to server-side matches', { narrowed });
});
});
@@ -0,0 +1,80 @@
// 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/>.
// The results-header breakpoint is a measured container width, so only a real viewport
// resize can fire it.
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const logData = require('../../fixtures/log.json');
const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
const { getOrgIdentifier } = require('../utils/cloud-auth.js');
const { LogsResultsToolbarPage } = require('../../pages/logsPages/logsResultsToolbarPage.js');
const STREAM = 'e2e_automate';
const WIDE_VIEWPORT = 1500;
// Below SearchResult.vue's 700px container threshold once the field-list sidebar is paid for.
const NARROW_VIEWPORT = 800;
test.describe('Logs results header overflow actions', () => {
let pm;
let toolbar;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
pm = new PageManager(page);
toolbar = new LogsResultsToolbarPage(page);
await ingestion(page);
await page.setViewportSize({ width: WIDE_VIEWPORT, height: 1024 });
await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`);
await pm.logsPage.selectStream(STREAM);
await pm.logsPage.clickDateTimeButton();
await pm.logsPage.clickRelative15MinButton();
await pm.logsPage.clickSearchBarRefreshButton();
await pm.logsPage.expectResultsGridSettledWithRows();
});
test('a narrow viewport moves the results actions into a working overflow menu', {
tag: ['@resultsToolbar', '@logs', '@P2', '@all'],
}, async ({ page }) => {
await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false });
const wrapBefore = await toolbar.readStoredWrapContent();
let searchCalls = 0;
page.on('request', (request) => {
if (request.url().includes('/_search')) searchCalls += 1;
});
await toolbar.setViewportWidthAndSettle(NARROW_VIEWPORT, { expectOverflow: true });
await toolbar.openOverflowMenu();
await toolbar.clickOverflowWrapItem();
await expect
.poll(() => toolbar.readStoredWrapContent(), {
timeout: 15000,
message: 'the overflow wrap item did not change the stored wrap preference',
})
.not.toBe(wrapBefore);
// Wrap is a render-only concern; re-querying on it would be a regression.
expect(searchCalls, 'wrapping from the overflow menu issued a search').toBe(0);
await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false });
testLogger.info('Actions collapsed below the breakpoint, stayed usable, and came back');
});
});
@@ -0,0 +1,122 @@
// 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/>.
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js');
const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js');
const { LogsSavedFunctionPage } = require('../../pages/logsPages/logsSavedFunctionPage.js');
const ORG = getOrgIdentifier();
const STAMP = Date.now();
const STREAM = `fnovr${STAMP}`;
const FUNCTION_NAME = `fnovr${STAMP}`;
// No quotes or brackets: Monaco auto-closes them, which would corrupt what the keystrokes leave in the editor.
const BODY_BEFORE = '.overwrite_marker = 101';
const BODY_AFTER = '.overwrite_marker = 202';
function apiBase() {
const url = process.env.ZO_BASE_URL;
return url.endsWith('/') ? url.slice(0, -1) : url;
}
function trackFunctionWrites(page) {
const sent = [];
page.on('request', (request) => {
if (/\/api\/[^/]+\/functions(\/|\?|$)/.test(request.url())) {
sent.push({ method: request.method(), url: request.url() });
}
});
return { of: (method) => sent.filter((entry) => entry.method === method) };
}
async function storedFunction(request, name) {
const response = await request.get(
`${apiBase()}/api/${ORG}/functions?page_num=1&page_size=1000&name=${name}`,
{ headers: getAuthHeaders() },
);
expect(response.ok(), await response.text()).toBe(true);
const body = await response.json();
return (body.list || []).filter((entry) => entry.name === name);
}
// beforeAll/afterAll run per worker, and this file shares one ingested stream plus one saved function.
test.describe.configure({ mode: 'serial' });
test.describe('Logs saved-function overwrite', () => {
let savedFunction;
test.beforeAll(async ({ request }) => {
const now = Date.now() * 1000;
const rows = Array.from({ length: 10 }, (_, i) => ({
_timestamp: now - 60e6 + i * 1000,
host: `web-${i}`,
body: `overwrite fixture ${i}`,
}));
await ingestRows(request, ORG, STREAM, rows);
await waitForSearchable(request, ORG, STREAM, rows.length);
});
test.afterAll(async ({ request }) => {
const fn = await request.delete(`${apiBase()}/api/${ORG}/functions/${FUNCTION_NAME}`, {
headers: getAuthHeaders(),
});
const stream = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, {
headers: getAuthHeaders(),
});
testLogger.info('Deleted exactly what this file created', {
function: [FUNCTION_NAME, fn.status()],
stream: [STREAM, stream.status()],
});
});
test('overwriting a saved function replaces the stored body and survives a reload', {
tag: ['@savedFunctionOverwrite', '@all', '@logs'],
}, async ({ page, request }) => {
testLogger.testStart('saved-function overwrite', __filename);
const writes = trackFunctionWrites(page);
await navigateToBase(page);
savedFunction = new LogsSavedFunctionPage(page);
await savedFunction.openLogs(ORG, STREAM);
await savedFunction.setFunctionBody(BODY_BEFORE);
await savedFunction.createFunction(FUNCTION_NAME);
await expect
.poll(async () => (await storedFunction(request, FUNCTION_NAME)).length, { timeout: 30000 })
.toBe(1);
await savedFunction.setFunctionBody(BODY_AFTER);
await savedFunction.overwriteFunction(FUNCTION_NAME);
// The PUT is what separates an overwrite from a second create.
await expect.poll(() => writes.of('PUT').length, { timeout: 30000 }).toBeGreaterThan(0);
const stored = await storedFunction(request, FUNCTION_NAME);
expect(stored).toHaveLength(1);
expect(stored[0].function).toContain(BODY_AFTER);
expect(stored[0].function).not.toContain(BODY_BEFORE);
await savedFunction.openLogs(ORG, STREAM);
await savedFunction.applySavedFunction(FUNCTION_NAME);
await expect
.poll(async () => savedFunction.functionBody(), { timeout: 30000 })
.toContain(BODY_AFTER);
expect(await savedFunction.functionBody()).not.toContain(BODY_BEFORE);
testLogger.info('Overwritten body round-tripped and survived the reload', {
function: FUNCTION_NAME,
});
});
});
-1
View File
@@ -364,7 +364,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="date-time-apply-btn"
variant="primary"
size="xs"
class="element-box-shadow"
@click="applyAndClose"
>
{{ t("common.apply") }}
+20
View File
@@ -22,6 +22,8 @@ import router from "@/test/unit/helpers/router";
import config from "@/aws-exports";
import { openobserveRum } from "@openobserve/browser-rum";
import { useLocalOrganization } from "@/utils/zincutils";
import configService from "@/services/config";
import { initBrowserRum } from "@/utils/rum/initBrowserRum";
vi.mock("@/services/config", () => ({
default: { get_config_full: vi.fn(() => new Promise(() => {})) },
@@ -32,6 +34,8 @@ vi.mock("@/utils/zincutils", async (importOriginal) => ({
invalidateLoginData: vi.fn(),
}));
vi.mock("@/utils/rum/initBrowserRum", () => ({ initBrowserRum: vi.fn() }));
vi.mock("@openobserve/browser-rum", () => ({
openobserveRum: {
setUser: vi.fn(),
@@ -289,6 +293,22 @@ describe("MainLayout — RUM identity and account", () => {
});
});
it("starts RUM from the authenticated full config", async () => {
const rum = { enabled: true, client_token: "token", application_id: "app", site: "rum" };
vi.mocked(configService.get_config_full).mockResolvedValueOnce({
data: { version: "v1", rum },
} as any);
store.state.zoConfig = { ...originalZoConfig, version: "" };
wrapper = mountMainLayout();
await flushPromises();
store.state.selectedOrganization = { label: "Rum", identifier: "rum-start" };
await flushPromises();
expect(configService.get_config_full).toHaveBeenCalledWith("rum-start");
expect(initBrowserRum).toHaveBeenCalledWith(rum, store.state.API_ENDPOINT);
});
it("does not set the RUM account when RUM is disabled", async () => {
store.state.zoConfig = { ...originalZoConfig, version: "v1", rum: { enabled: false } };
+4 -6
View File
@@ -319,6 +319,7 @@ import {
type OrgStreamList,
} from "@/composables/firstEvent/useFirstDataNotice";
import { openobserveRum } from "@openobserve/browser-rum";
import { initBrowserRum } from "@/utils/rum/initBrowserRum";
import useSearchWebSocket from "@/composables/useSearchWebSocket";
import O2AIChat from "@/components/O2AIChat.vue";
import PaidOverageConsentDialog from "@/components/PaidOverageConsentDialog.vue";
@@ -844,7 +845,7 @@ export default defineComponent({
},
);
// main.ts mounts before the bootstrap config resolves, so rum.enabled can arrive after the org.
// rum.enabled arrives only with the full config, which is fetched after the org is known.
watch(
[
() => store.state.selectedOrganization?.identifier,
@@ -1541,6 +1542,8 @@ export default defineComponent({
linksList.value = layoutMixin.leftNavigationLinks(linksList, t);
}
// Before setConfig, so watchers that react to rum.enabled find the SDK running.
initBrowserRum(res.data.rum, store.state.API_ENDPOINT);
store.dispatch("setConfig", res.data);
fullConfigOrg = orgIdentifier;
await nextTick();
@@ -1567,11 +1570,6 @@ export default defineComponent({
}
// Fail open: reveal the base menu even if the config never resolves.
menuReady.value = true;
// Session replay must not be lost to a failed config fetch — the rum
// settings already arrived with the unauthenticated bootstrap.
if (store.state.zoConfig?.rum?.enabled) {
setRumUser();
}
// A transient failure (expired session being refreshed, rolling
// deploy) would otherwise strand the session on the bootstrap subset.
if (attempt < FULL_CONFIG_RETRY_DELAYS_MS.length) {
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "لا توجد نتائج مطابقة",
"runQuery": "تشغيل الاستعلام",
"runOptions": "خيارات التشغيل",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "قيم التوصيف عبر الزمن",
"breakdown": "التفصيل",
"breakdownNone": "بدون",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Keine Treffer",
"runQuery": "Abfrage ausführen",
"runOptions": "Ausführungsoptionen",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Profilwerte im Zeitverlauf",
"breakdown": "Aufschlüsselung",
"breakdownNone": "Keine",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "No matches",
"runQuery": "Run query",
"runOptions": "Run Options",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Profile values over time",
"breakdown": "Breakdown",
"breakdownNone": "None",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Sin coincidencias",
"runQuery": "Ejecutar consulta",
"runOptions": "Opciones de ejecución",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Valores de perfil a lo largo del tiempo",
"breakdown": "Desglose",
"breakdownNone": "Ninguno",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Aucune correspondance",
"runQuery": "Exécuter la requête",
"runOptions": "Options d'exécution",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Valeurs de profil dans le temps",
"breakdown": "Répartition",
"breakdownNone": "Aucun",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Nessuna corrispondenza",
"runQuery": "Esegui query",
"runOptions": "Opzioni di esecuzione",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Valori dei profili nel tempo",
"breakdown": "Ripartizione",
"breakdownNone": "Nessuno",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "一致なし",
"runQuery": "クエリを実行",
"runOptions": "実行オプション",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "プロファイル値の推移",
"breakdown": "内訳",
"breakdownNone": "なし",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "일치 항목 없음",
"runQuery": "쿼리 실행",
"runOptions": "실행 옵션",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "시간 경과에 따른 프로파일 값",
"breakdown": "분류",
"breakdownNone": "없음",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Geen overeenkomsten",
"runQuery": "Query uitvoeren",
"runOptions": "Uitvoeropties",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Profielwaarden in de tijd",
"breakdown": "Uitsplitsing",
"breakdownNone": "Geen",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Brak dopasowań",
"runQuery": "Uruchom zapytanie",
"runOptions": "Opcje uruchamiania",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Wartości profilu w czasie",
"breakdown": "Podział",
"breakdownNone": "Brak",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Sem correspondências",
"runQuery": "Executar consulta",
"runOptions": "Opções de execução",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Valores de perfil ao longo do tempo",
"breakdown": "Desagregação",
"breakdownNone": "Nenhum",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Совпадений нет",
"runQuery": "Выполнить запрос",
"runOptions": "Параметры запуска",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Значения профиля во времени",
"breakdown": "Разбивка",
"breakdownNone": "Нет",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Eşleşme yok",
"runQuery": "Sorguyu çalıştır",
"runOptions": "Çalıştırma seçenekleri",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Zaman içinde profil değerleri",
"breakdown": "Kırılım",
"breakdownNone": "Hiçbiri",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "Không có kết quả khớp",
"runQuery": "Chạy truy vấn",
"runOptions": "Tùy chọn chạy",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "Giá trị profile theo thời gian",
"breakdown": "Phân tách",
"breakdownNone": "Không có",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "无匹配项",
"runQuery": "运行查询",
"runOptions": "运行选项",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "性能值时间序列",
"breakdown": "细分",
"breakdownNone": "无",
+1
View File
@@ -1518,6 +1518,7 @@
"searchNoMatches": "無相符項目",
"runQuery": "執行查詢",
"runOptions": "執行選項",
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
"timeSeriesTitle": "剖析值隨時間變化",
"breakdown": "細分",
"breakdownNone": "無",
+13 -78
View File
@@ -5,8 +5,7 @@ const routerOnError = vi.hoisted(() => vi.fn());
const isStaleChunkError = vi.hoisted(() => vi.fn());
const toast = vi.hoisted(() => vi.fn());
const logsInit = vi.hoisted(() => vi.fn());
const dispatch = vi.hoisted(() => vi.fn());
const route = vi.hoisted(() => ({ name: "logs" }));
const setInitialVersion = vi.hoisted(() => vi.fn());
vi.mock("@/services/config.queries", () => ({ configQuery: () => ({}) }));
vi.mock("vue", async (importOriginal) => {
@@ -16,16 +15,10 @@ vi.mock("vue", async (importOriginal) => {
});
vi.mock("@tanstack/vue-query", () => ({ VueQueryPlugin: {} }));
vi.mock("./stores", () => ({
default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch },
default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch: vi.fn() },
}));
vi.mock("./App.vue", () => ({ default: {} }));
vi.mock("./router", () => ({
default: () => ({
onError: routerOnError,
resolve: () => ({ name: route.name }),
options: { history: { location: "/" } },
}),
}));
vi.mock("./router", () => ({ default: () => ({ onError: routerOnError }) }));
vi.mock("./locales", () => ({
default: { global: { t: (key: string) => key } },
applyDocumentLocale: vi.fn(),
@@ -41,7 +34,7 @@ vi.mock("./composables/contextProviders", () => ({
createDefaultContextProvider: vi.fn(),
}));
vi.mock("./utils/buildVersionChecker", () => ({
buildVersionChecker: { setInitialVersion: vi.fn(), isStaleChunkError },
buildVersionChecker: { setInitialVersion, isStaleChunkError },
isChunkLoadError: (e: { message?: string } | undefined) =>
/dynamically imported module|Unable to preload CSS/.test(e?.message ?? ""),
}));
@@ -49,6 +42,8 @@ vi.mock("./composables/query/queryClient", () => ({
queryClient: {
fetchQuery: () =>
Promise.resolve({
commit_hash: "abc123",
// A stale server may still send rum in the bootstrap; the SDK must wait for login anyway.
rum: {
enabled: true,
client_token: "t",
@@ -66,63 +61,18 @@ vi.mock("./composables/query/queryClient", () => ({
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast }));
vi.mock("@/utils/themeManager", () => ({ bootstrapTheme: vi.fn() }));
const EXT = "chrome-extension://abcdefghijklmnop";
// main.ts boots the whole app on import, which can outlast the default hook timeout on a busy runner.
beforeAll(async () => {
await import("./main");
await vi.waitFor(() => expect(rumInit).toHaveBeenCalled(), { timeout: 20000 });
await vi.waitFor(() => expect(setInitialVersion).toHaveBeenCalledWith("abc123"), {
timeout: 20000,
});
}, 60000);
const rumBeforeSend = (): ((event: Record<string, unknown>) => boolean) =>
rumInit.mock.calls[0][0].beforeSend;
const errorEvent = (stack: string) => ({
type: "error",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
error: { message: "boom", stack },
});
describe("main.ts RUM version", () => {
it("sends the server's build version to both the RUM and logs SDKs", () => {
expect(rumInit.mock.calls[0][0].version).toBe("v0.70.0");
expect(logsInit.mock.calls[0][0].version).toBe("v0.70.0");
});
});
describe("main.ts RUM beforeSend", () => {
it("drops an error whose every frame is a Chrome extension", async () => {
const beforeSend = rumBeforeSend();
const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`;
expect(beforeSend(errorEvent(stack))).toBe(false);
});
it("sends an error with at least one app frame", async () => {
const beforeSend = rumBeforeSend();
const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at setup (https://cloud.openobserve.ai/web/assets/index.js:3:4)`;
expect(beforeSend(errorEvent(stack))).toBe(true);
});
it("still scrubs sign-in tokens from the view URL of an event it sends", async () => {
const beforeSend = rumBeforeSend();
const event = {
type: "view",
view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" },
};
expect(beforeSend(event)).toBe(true);
expect(event.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
});
it("drops a JWT from a click's target name", async () => {
const beforeSend = rumBeforeSend();
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln";
const event = {
type: "action",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
action: { target: { name: `Copy ${jwt}` } },
};
expect(beforeSend(event)).toBe(true);
expect(event.action.target.name).toBe("Copy ");
describe("main.ts RUM", () => {
it("does not start the RUM or logs SDK from the unauthenticated bootstrap config", () => {
expect(rumInit).not.toHaveBeenCalled();
expect(logsInit).not.toHaveBeenCalled();
});
});
@@ -226,18 +176,3 @@ describe("main.ts chunk load errors", () => {
60000,
);
});
describe("main.ts RUM on public dashboards", () => {
it("starts neither the RUM nor the logs SDK for an anonymous public-link viewer", async () => {
route.name = "publicDashboard";
rumInit.mockClear();
logsInit.mockClear();
dispatch.mockClear();
vi.resetModules();
await import("./main");
await vi.waitFor(() => expect(dispatch).toHaveBeenCalledWith("setConfig", expect.anything()));
expect(rumInit).not.toHaveBeenCalled();
expect(logsInit).not.toHaveBeenCalled();
route.name = "logs";
}, 60000);
});
-148
View File
@@ -28,16 +28,10 @@ import "./styles/rtl.css";
import "./assets/styles/log-highlighting.css";
import config from "./aws-exports";
import { openobserveRum } from "@openobserve/browser-rum";
import { openobserveLogs } from "@openobserve/browser-logs";
import { useReo } from "./services/reodotdev_analytics";
import { contextRegistry, createDefaultContextProvider } from "./composables/contextProviders";
import { buildVersionChecker, isChunkLoadError } from "./utils/buildVersionChecker";
import { queryClient, setMutationNotifier } from "./composables/query/queryClient";
import { shouldPropagateTracing } from "./utils/rum/tracingOrigin";
import { isIgnoredNoise } from "./utils/rum/ignoredNoisePatterns";
import { scrubAuthParams } from "./utils/rum/scrubAuthParams";
import { isExtensionOnlyError } from "./utils/rum/extensionFrames";
import { toast } from "@/lib/feedback/Toast/useToast";
import { bootstrapTheme } from "@/utils/themeManager";
import { raw } from "@/types/i18n";
@@ -80,28 +74,9 @@ interface ConfigResponse {
telemetry_enabled?: boolean;
build_type?: string;
commit_hash?: string;
rum?: {
enabled: boolean;
client_token: string;
application_id: string;
site: string;
service: string;
env: string;
version?: string;
organization_identifier: string;
insecure_http?: boolean;
api_version?: string;
};
};
}
// Only the three dot-separated parts make a token, so a bare eyJ inside a word (surveyJsonBtn) is kept.
const JWT_PATTERN = /eyJ[\w-]*\.[\w-]+\.[\w-]*/g;
// Anonymous public-link viewers are not app users, so their sessions stay out of RUM.
const isPublicDashboard =
router.resolve(router.options.history.location).name === "publicDashboard";
const getConfig = async () => {
// Seeds the shared bootstrap `/config` query — Login and the version checker
// read the same cached entry instead of each issuing their own request. The
@@ -122,129 +97,6 @@ const getConfig = async () => {
if (res.data.commit_hash) {
buildVersionChecker.setInitialVersion(res.data.commit_hash);
}
if (res.data.rum?.enabled && !isPublicDashboard) {
const options = {
clientToken: res.data.rum.client_token,
applicationId: res.data.rum.application_id,
site: res.data.rum.site,
service: res.data.rum.service,
env: res.data.rum.env,
version: res.data.rum.version || "0.0.1",
organizationIdentifier: res.data.rum.organization_identifier,
insecureHTTP: res.data.rum.insecure_http || false,
apiVersion: res.data.rum.api_version || "v1",
};
openobserveRum.init({
applicationId: options.applicationId, // required, any string identifying your application
clientToken: options.clientToken,
site: options.site,
organizationIdentifier: options.organizationIdentifier,
service: options.service,
env: options.env,
version: options.version,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
actionNameAttribute: "data-test",
apiVersion: options.apiVersion,
insecureHTTP: options.insecureHTTP,
defaultPrivacyLevel: "allow",
// Session renewal drops the forced replay start, so sampling must keep every renewed session recording.
sessionReplaySampleRate: 100,
startSessionReplayRecordingManually: true,
// Same-origin only: cross-origin (dev against a remote cluster) the
// injected headers fail the CORS preflight and kill every API call.
// See shouldPropagateTracing.
allowedTracingUrls: shouldPropagateTracing(store.state.API_ENDPOINT, window.location.origin)
? [
{
match: store.state.API_ENDPOINT + "/api",
propagatorTypes: ["openobserve", "tracecontext"],
},
]
: [],
beforeSend: (event) => {
event.view.url = scrubAuthParams(event.view.url) ?? event.view.url;
event.view.referrer = scrubAuthParams(event.view.referrer);
if (event.type === "resource") {
event.resource.url = scrubAuthParams(event.resource.url) ?? event.resource.url;
}
if (event.type === "error") {
event.error.message = scrubAuthParams(event.error.message) ?? event.error.message;
if (event.error.resource) {
event.error.resource.url =
scrubAuthParams(event.error.resource.url) ?? event.error.resource.url;
}
}
if (event.type === "error" && isExtensionOnlyError(event.error?.stack)) return false;
if (event.type === "action" && event.action.target?.name) {
event.action.target.name = event.action.target.name.replace(JWT_PATTERN, "");
}
// Filter out specific errors before sending to RUM
if (event.type === "error") {
const errorMessage = event.error?.message || "";
const errorStack = event.error?.stack || "";
// List of error patterns to ignore
const ignoredErrorPatterns = [
/ResizeObserver loop/i,
/reo.dev/i,
/Cannot set properties of null \(setting 'innerHTML'\)/,
];
// Check if error matches any ignored pattern
const shouldIgnore = ignoredErrorPatterns.some(
(pattern) => pattern.test(errorMessage) || pattern.test(errorStack),
);
if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) {
return false; // Don't send this error
}
}
// Allow all other events to be sent
return true;
},
});
openobserveLogs.init({
clientToken: options.clientToken,
site: options.site,
organizationIdentifier: options.organizationIdentifier,
service: options.service,
env: options.env,
version: options.version,
forwardErrorsToLogs: true,
insecureHTTP: options.insecureHTTP,
apiVersion: options.apiVersion,
beforeSend: (log) => {
if (log.view?.url) log.view.url = scrubAuthParams(log.view.url) ?? log.view.url;
if (log.view?.referrer) log.view.referrer = scrubAuthParams(log.view.referrer);
if (log.http?.url) log.http.url = scrubAuthParams(log.http.url) ?? log.http.url;
log.message = scrubAuthParams(log.message) ?? log.message;
// Filter out specific logs before sending
const logMessage = log.message || "";
// List of log patterns to ignore
const ignoredLogPatterns = [/ResizeObserver loop/i];
// Check if log matches any ignored pattern
const shouldIgnore = ignoredLogPatterns.some((pattern) => pattern.test(logMessage));
if (shouldIgnore || isIgnoredNoise(logMessage)) {
return false; // Don't send this log
}
// Allow all other logs to be sent
return true;
},
});
// Don't start session replay automatically - it will be started after login
// to avoid capturing login page with "Unknown" user
// Session replay will be started in Login.vue and MainLayout.vue after user is authenticated
}
});
};
+3 -4
View File
@@ -14,9 +14,7 @@ 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>
<OButtonGroup
class="function-selector element-box-shadow border-button-outline-border float-left me-1 border p-0"
>
<OButtonGroup class="function-selector border-button-outline-border float-left me-1 border p-0">
<div v-if="!hideToggle" class="flex items-center px-1">
<OSwitch
data-test="logs-search-bar-show-query-toggle-btn"
@@ -30,7 +28,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
data-test="logs-search-bar-function-dropdown"
variant="ghost"
class="ms-1!"
class="ms-1! size-8!"
size="icon-toolbar"
>
<img :src="functionIconUrl" :alt="t('logs.functionSelector.function')" class="size-4" />
@@ -74,6 +72,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="logs-search-bar-save-function-btn"
variant="ghost"
size="icon-toolbar"
class="size-8!"
@click="fnSavedFunctionDialog"
>
<OIcon name="save" size="sm" />
@@ -254,4 +254,25 @@ describe("SearchBar — pinned saved views", () => {
expect(group().exists()).toBe(false);
});
// 89px = 1px border + 54px list trigger + 1px separator + 32px create button + 1px border.
it.each([
[698, false],
[699, true],
])(
"budgets the group at its rendered 89px width (toolbar %ipx → shown: %s)",
async (width, shown) => {
const otherPins = (["histogram", "sqlMode", "quickMode", "syntaxGuide"] as const).filter(
(key) => isPinned(key),
);
otherPins.forEach((key) => togglePin(key));
try {
wrapper = mountSearchBar(width);
await flushPromises();
expect(group().exists()).toBe(shown);
} finally {
otherPins.forEach((key) => togglePin(key));
}
},
);
});
@@ -102,4 +102,48 @@ describe("SearchBar — toolbar controls match the view-mode toggle height", ()
expectToolbarHeight("logs-view-mode-dropdown-btn");
expectToolbarHeight("logs-search-bar-utilities-menu-btn");
});
it("raises every right-hand control to the same 34px row height", async () => {
const innerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { value: 1600, configurable: true });
if (!isPinned("functionEditor")) {
togglePin("functionEditor");
pinnedByTest.push("functionEditor");
}
try {
wrapper = mountSearchBar(1600);
await flushPromises();
wrapper.vm.searchObj.meta.showTransformEditor = true;
await flushPromises();
const classesOf = (dataTest: string) => wrapper!.get(`[data-test="${dataTest}"]`).classes();
for (const dataTest of [
"logs-search-bar-more-options-btn",
"logs-search-bar-share-link-btn",
]) {
expect(classesOf(dataTest)).toContain("size-[2.125rem]!");
}
for (const dataTest of [
"logs-search-bar-function-editor-pinned-btn",
"logs-search-bar-date-time-dropdown",
"logs-search-bar-refresh-btn",
"logs-search-bar-refresh-cache-dropdown-trigger",
]) {
expect(classesOf(dataTest)).toContain(TOGGLE_GROUP_HEIGHT_CLASS);
}
const runButton = wrapper.get('[data-test="logs-search-bar-refresh-btn"]').element;
expect(runButton.nextElementSibling!.classList).toContain(TOGGLE_GROUP_HEIGHT_CLASS);
const interval = wrapper.get('[data-test="logs-search-bar-refresh-interval-btn"]').element;
expect(interval.parentElement!.classList).toContain("*:size-[2.125rem]!");
// A 1px group border around 2rem buttons makes the function selector 34px.
for (const dataTest of [
"logs-search-bar-function-dropdown",
"logs-search-bar-save-function-btn",
]) {
expect(classesOf(dataTest)).toContain("size-8!");
}
} finally {
Object.defineProperty(window, "innerWidth", { value: innerWidth, configurable: true });
}
});
});
@@ -0,0 +1,109 @@
// 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, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import { createRouter, createMemoryHistory } from "vue-router";
import SearchBar from "@/plugins/logs/SearchBar.vue";
import { resetLogsAutoRunForTests } from "@/composables/useLogs/logsAutoRun";
import { useToolbarPins, type ToolbarPinKey } from "@/composables/useToolbarPins";
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/logs", name: "logs", component: { template: "<div />" } }],
});
const UNPREFIXED_HORIZONTAL_MARGIN = /^-?m[xse]?-|^-?m[lr]-/;
describe("SearchBar — toolbar controls are spaced only by the row gap", () => {
let wrapper: VueWrapper<any> | undefined;
const { isPinned, togglePin } = useToolbarPins();
const pinnedByTest: ToolbarPinKey[] = [];
beforeEach(() => {
resetLogsAutoRunForTests(store);
for (const key of [
"histogram",
"sqlMode",
"quickMode",
"savedViews",
"syntaxGuide",
] as ToolbarPinKey[]) {
if (!isPinned(key)) {
togglePin(key);
pinnedByTest.push(key);
}
}
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
this: HTMLElement,
) {
return { width: this.classList.contains("border-b-card-glass-border") ? 1600 : 0 } as DOMRect;
});
});
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
while (pinnedByTest.length) togglePin(pinnedByTest.pop()!);
vi.restoreAllMocks();
});
const marginClassesOf = (el: Element) =>
[...el.classList].filter((c) => UNPREFIXED_HORIZONTAL_MARGIN.test(c));
it("adds no horizontal margin on top of the gap-1 between adjacent controls", async () => {
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true },
},
});
await flushPromises();
const left = wrapper.vm.$refs.toolbarLeftRef as HTMLElement;
const collapsible = left.firstElementChild as HTMLElement;
expect(left.classList).toContain("gap-1");
expect(collapsible.classList).toContain("gap-1");
const more = wrapper.get('[data-test="logs-search-bar-utilities-menu-btn"]');
expect(marginClassesOf(more.element)).toEqual([]);
expect(more.classes()).toContain("max-md:ms-auto");
const controls = [...collapsible.children, ...[...left.children].slice(1)];
for (const dataTest of [
"logs-logs-toggle",
"logs-search-bar-reset-filters-btn",
"logs-search-bar-histogram-btn",
"logs-search-bar-sql-mode-pinned-btn",
"logs-search-bar-quick-mode-pinned-btn",
"logs-search-bar-saved-views-pinned",
]) {
expect(
controls.some(
(c) =>
c.querySelector(`[data-test="${dataTest}"]`) || c.matches(`[data-test="${dataTest}"]`),
),
).toBe(true);
}
expect(controls.length).toBeGreaterThanOrEqual(8);
for (const control of controls) {
expect(marginClassesOf(control), control.outerHTML.slice(0, 120)).toEqual([]);
}
});
});
+23 -21
View File
@@ -243,7 +243,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButtonGroup
v-if="showPinnedSavedViews"
data-test="logs-search-bar-saved-views-pinned"
class="element-box-shadow border-button-outline-border border p-0"
class="border-button-outline-border border p-0"
>
<!-- A real dropdown, not a modal: one click to open, one click to
apply. The heavyweight list dialog stays reachable via Manage. -->
@@ -365,7 +365,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #trigger>
<OButton
data-test="logs-search-bar-utilities-menu-btn"
class="rounded-default hover:bg-button-outline-hover-bg element-box-shadow ms-1 h-[2.125rem]! p-1! text-xs font-medium [border:0.0625rem_solid_var(--color-button-outline-border)]! [transition:all_0.2s_ease] max-md:ms-auto"
class="rounded-default hover:bg-button-outline-hover-bg h-[2.125rem]! p-1! text-xs font-medium [border:0.0625rem_solid_var(--color-button-outline-border)]! [transition:all_0.2s_ease] max-md:ms-auto"
icon-left="more-horiz"
variant="outline"
size="xs"
@@ -650,7 +650,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #trigger>
<OButton
data-test="logs-search-bar-more-options-btn"
class="download-logs-btn hover:bg-interactive-hover-bg! order-4"
class="download-logs-btn hover:bg-interactive-hover-bg! order-4 size-[2.125rem]!"
variant="outline"
size="icon-toolbar"
>
@@ -912,7 +912,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
size="icon-toolbar"
:disabled="!!shareReason"
:tooltip="shareReason || undefined"
class="order-3"
class="order-3 size-[2.125rem]!"
/>
<!-- Function Editor (pinned) — sits to the left of the date picker -->
<OButton
@@ -921,7 +921,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="logs-search-bar-function-editor-pinned-btn"
size="xs"
variant="outline"
class="element-box-shadow order-1 me-1 gap-1.5"
class="order-1 me-1 h-[2.125rem]! gap-1.5"
@click="searchObj.meta.showTransformEditor = !searchObj.meta.showTransformEditor"
>
<OSwitch
@@ -952,14 +952,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@on:date-change="updateDateTime"
@on:timezone-change="updateTimezone"
:disable="disable"
class="element-box-shadow"
class="h-[2.125rem]!"
/>
</div>
<div class="search-time order-2">
<div class="flex">
<OButtonGroup
class="element-box-shadow border-card-glass-border me-1 border p-0"
class="border-card-glass-border me-1 border p-0"
v-if="
config.isEnterprise == 'true' &&
Object.keys(store.state.regionInfo).length > 0 &&
@@ -1010,7 +1010,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:title="t('search.cancel')"
variant="ghost"
size="sm-toolbar"
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
@click="cancelVisualizeQueries"
>{{ t("search.cancel") }}</OButton
>
@@ -1021,17 +1021,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:title="t('search.runQuery')"
:disabled="buildRunBlocked"
size="sm-toolbar"
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
class="bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
@click="handleRunQueryFn()"
>
{{ t("search.runQuery") }}
</OButton>
<OSeparator class="h-[1.875rem]! w-px" vertical />
<OSeparator class="h-[2.125rem]! w-px" vertical />
<ODropdown align="end" side="bottom">
<template #trigger>
<OButton
variant="ghost"
size="icon-xs"
class="h-[2.125rem]!"
:aria-label="t('search.refreshCacheAndRunQuery')"
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
:class="[
@@ -1066,7 +1067,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
:title="t('search.cancel')"
size="sm-toolbar"
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
@click="cancelVisualizeQueries"
>{{ t("search.cancel") }}</OButton
>
@@ -1078,17 +1079,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:title="t('search.runQuery')"
:disabled="buildRunBlocked || disable"
size="sm-toolbar"
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
class="bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
@click="handleRunQueryFn()"
>
{{ t("search.runQuery") }}
</OButton>
<OSeparator class="h-[1.875rem]! w-px" vertical />
<OSeparator class="h-[2.125rem]! w-px" vertical />
<ODropdown align="end" side="bottom">
<template #trigger>
<OButton
variant="ghost"
size="icon-xs"
class="h-[2.125rem]!"
:aria-label="t('search.refreshCacheAndRunQuery')"
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
:class="[
@@ -1124,7 +1126,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
:title="t('search.cancel')"
size="sm-toolbar"
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-default h-[1.875rem]! w-[5.875rem]! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-default h-[2.125rem]! w-[5.875rem]! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
@click="autoRun.engine.cancelGeneration(null, { cause: 'user' })"
>{{ t("search.cancel") }}</OButton
>
@@ -1135,7 +1137,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="primary"
:title="t('search.cancel')"
size="sm-toolbar"
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
@click="cancelRun"
>{{ t("search.cancel") }}</OButton
>
@@ -1148,7 +1150,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:variant="showRunQueryPending ? 'pending' : 'primary'"
:title="t('search.runQuery')"
size="sm-toolbar"
class="element-box-shadow rounded-s-default! relative h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
class="rounded-s-default! relative h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
:class="[
showRunQueryPending
? ''
@@ -1182,7 +1184,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<OSeparator
v-if="!showRunQueryPending || isRunQueryCancelShown"
class="h-[1.875rem]! w-px"
class="h-[2.125rem]! w-px"
vertical
/>
<ODropdown align="end" side="bottom">
@@ -1190,6 +1192,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:variant="showRunQueryPending && !isRunQueryCancelShown ? 'pending' : 'ghost'"
size="icon-xs"
class="h-[2.125rem]!"
:aria-label="t('search.refreshCacheAndRunQuery')"
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
:class="[
@@ -1247,7 +1250,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</ODropdown>
<!-- Compact Auto Refresh Button -->
<AutoRefreshInterval
class="ms-1"
class="ms-1 *:size-[2.125rem]!"
v-model="searchObj.meta.refreshInterval"
:trigger="true"
:is-compact="true"
@@ -2445,7 +2448,7 @@ export default defineComponent({
// Approximate rendered widths (px) of each pinned control and of the fixed
// left-section content, used only to decide how many pinned items fit before
// they would clip. Hidden pinned items stay reachable inside the More menu.
const PIN_ITEM_WIDTH = { histogram: 46, sqlMode: 46, quickMode: 46, savedViews: 87 };
const PIN_ITEM_WIDTH = { histogram: 46, sqlMode: 46, quickMode: 46, savedViews: 89 };
const SYNTAX_GUIDE_LABEL_WIDTH = 108;
const SYNTAX_GUIDE_ICON_WIDTH = 40;
const PIN_ITEM_GAP = 4;
@@ -5393,7 +5396,6 @@ export default defineComponent({
}
.logs-search-bar-component .download-logs-btn {
height: 1.875rem;
border-radius: var(--radius-default);
transition: all 0.2s ease;
}
@@ -5415,7 +5417,7 @@ export default defineComponent({
font-weight: 600;
font-size: var(--text-xs);
padding-left: 0.5rem;
height: 1.875rem;
height: 2rem;
padding-top: 0.1875rem;
border-radius: var(--radius-default);
}
+171 -5
View File
@@ -251,7 +251,9 @@ describe("Profiles page", () => {
await flushPromises();
await nextTick();
expect(mocks.meta).toHaveBeenCalledTimes(1);
// Once to learn the services, once more for the picked service's profile types.
expect(mocks.meta).toHaveBeenCalledTimes(2);
expect(mocks.meta.mock.calls[1][2]).toMatchObject({ service_name: "service-a" });
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.series).toHaveBeenCalledTimes(1);
});
@@ -265,7 +267,7 @@ describe("Profiles page", () => {
await flushPromises();
expect(wrapper.findComponent({ name: "DateTime" }).exists()).toBe(true);
expect(mocks.meta).toHaveBeenCalledTimes(1);
expect(mocks.meta).toHaveBeenCalledTimes(2);
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.series).toHaveBeenCalledTimes(1);
});
@@ -945,7 +947,8 @@ describe("Profiles page", () => {
valueType: "relative",
});
await flushPromises();
expect(mocks.meta).toHaveBeenCalledTimes(2);
// Two on open, one for the new service's profile types, one for the new window.
expect(mocks.meta).toHaveBeenCalledTimes(4);
expect(mocks.merge).toHaveBeenCalledTimes(4);
});
@@ -967,6 +970,110 @@ describe("Profiles page", () => {
expect(wrapper.find('[data-test="profiles-no-results"]').exists()).toBe(false);
});
it("lists only the profile types of the selected service", async () => {
const typesByService: Record<string, Array<{ type: string; unit: string }>> = {
"service-a": [
{ type: "cpu", unit: "nanoseconds" },
{ type: "heap", unit: "bytes" },
],
checkout: [{ type: "cpu", unit: "nanoseconds" }],
};
mocks.meta.mockImplementation(async (_org, _stream, payload) => ({
data: {
...profileMetaResponse.data,
services: ["service-a", "checkout"],
profile_types: payload.service_name
? typesByService[payload.service_name]
: Object.values(typesByService).flat(),
},
}));
const wrapper = mountPage();
await settle();
const typeOptions = () =>
wrapper
.findAll('[data-test="profiles-type-select"] option')
.map((option) => option.attributes("value"));
expect(typeOptions()).toEqual(["cpu\u0000nanoseconds", "heap\u0000bytes"]);
await wrapper.find('[data-test="profiles-service-select"] select').setValue("checkout");
await flushPromises();
expect(mocks.meta.mock.lastCall?.[2]).toMatchObject({ service_name: "checkout" });
expect(typeOptions()).toEqual(["cpu\u0000nanoseconds"]);
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({
service_name: "checkout",
profile_type: "cpu",
});
});
it("keeps the chosen service through an empty window and says why Run query is off", async () => {
let empty = false;
mocks.meta.mockImplementation(async () => ({
data: empty
? { ...profileMetaResponse.data, services: [], profile_types: [], label_names: [] }
: { ...profileMetaResponse.data, services: ["service-a", "payments"] },
}));
const wrapper = mountPage();
await settle();
await wrapper.find('[data-test="profiles-service-select"] select').setValue("payments");
await flushPromises();
const picker = wrapper.findComponent({ name: "DateTimePickerDashboard" });
const pickWindow = async (relativeTimePeriod: string) => {
picker.vm.$emit("update:modelValue", {
startTime: 0,
endTime: 0,
relativeTimePeriod,
valueType: "relative",
});
await flushPromises();
};
empty = true;
await pickWindow("5m");
const run = wrapper.find('[data-test="profiles-run-query"]');
expect(run.attributes("disabled")).toBeDefined();
expect(run.find("span[content]").attributes("content")).toBe(
"Pick a stream and a profile type with data in this time range",
);
empty = false;
await pickWindow("1h");
expect(mocks.meta.mock.lastCall?.[2]).toMatchObject({ service_name: "payments" });
expect(
(wrapper.find('[data-test="profiles-service-select"] select').element as HTMLSelectElement)
.value,
).toBe("payments");
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({ service_name: "payments" });
});
it("draws a lone point as a dot on an axis that spans the run's window", async () => {
const startMs = mocks.nowMs - 15 * 60 * 1000;
mocks.series.mockResolvedValue({
data: { ...seriesResponse.data, series: [{ timestamp: startMs * 1000, value: 6e9 }] },
});
const wrapper = mountPage();
await settle();
const options = () =>
wrapper.findComponent({ name: "ChartRendererStub" }).props("data").options;
expect(options().series[0].showSymbol).toBe(true);
expect(options().xAxis.min).toBe(startMs);
expect(options().xAxis.max).toBe(mocks.nowMs);
mocks.series.mockResolvedValue({
data: {
...seriesResponse.data,
series: [0, 1, 2].map((step) => ({
timestamp: (startMs + step * 60_000) * 1000,
value: 6e9,
})),
},
});
await wrapper.find('[data-test="profiles-run-query"]').trigger("click");
await settle();
expect(options().series[0].showSymbol).toBe(false);
});
describe("URL and share link", () => {
const shareUrl = (wrapper: ReturnType<typeof mountPage>) =>
new URL(String(wrapper.findComponent({ name: "ShareButton" }).props("url")));
@@ -1056,6 +1163,60 @@ describe("Profiles page", () => {
expect(focusCalls).toHaveBeenCalledWith("root/0");
});
it("drops the flame graph focus when a new run returns another tree", async () => {
mocks.merge.mockResolvedValue(mergeResponse);
route.query = {
org_identifier: orgIdentifier,
stream: "profiles-a",
period: "15m",
view: "flame",
focus: "root/0",
};
focusCalls.mockClear();
const wrapper = mountWithFlame();
await settle();
expect(focusCalls).toHaveBeenCalledWith("root/0");
wrapper.findComponent({ name: "FlameGraphStub" }).vm.$emit("focus", "root/0/2");
await flushPromises();
expect(router.replace.mock.lastCall?.[0].query.focus).toBe("root/0/2");
focusCalls.mockClear();
await wrapper.find('[data-test="profiles-run-query"]').trigger("click");
await settle();
expect(wrapper.findComponent({ name: "FlameGraphStub" }).exists()).toBe(true);
expect(focusCalls).not.toHaveBeenCalled();
expect(router.replace.mock.lastCall?.[0].query).not.toHaveProperty("focus");
expect(Object.fromEntries(shareUrl(wrapper).searchParams)).not.toHaveProperty("focus");
});
it("drops a link's focus when metadata replaces the linked service and runs again", async () => {
mocks.merge.mockResolvedValue(mergeResponse);
route.query = {
org_identifier: orgIdentifier,
stream: "profiles-a",
period: "15m",
service_name: "service-gone",
profile_type: "cpu",
profile_unit: "nanoseconds",
view: "flame",
focus: "root/0",
};
// The linked run answers only after the second run started, so it is superseded.
let answerLinkedRun!: (value: typeof mergeResponse) => void;
mocks.merge.mockReturnValueOnce(new Promise((resolve) => (answerLinkedRun = resolve)));
focusCalls.mockClear();
mountWithFlame();
await settle();
answerLinkedRun(mergeResponse);
await settle();
expect(mocks.merge).toHaveBeenCalledTimes(2);
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({ service_name: "service-a" });
expect(focusCalls).not.toHaveBeenCalled();
expect(router.replace.mock.lastCall?.[0].query).not.toHaveProperty("focus");
});
it("shares the window of the last run, not of the first one", async () => {
const wrapper = mountPage();
await flushPromises();
@@ -1469,6 +1630,7 @@ describe("Profiles page", () => {
expect(mocks.meta).toHaveBeenCalledWith(orgIdentifier, "profiles-b", {
start_time: FROM,
end_time: TO,
service_name: "service-b",
});
});
@@ -1593,8 +1755,12 @@ describe("Profiles page", () => {
(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.meta).toHaveBeenCalledTimes(2);
expect(mocks.meta).toHaveBeenNthCalledWith(1, orgIdentifier, "profiles-a", window);
expect(mocks.meta).toHaveBeenNthCalledWith(2, orgIdentifier, "profiles-a", {
...window,
service_name: "service-a",
});
expect(mocks.merge).toHaveBeenCalledTimes(1);
expect(mocks.merge).toHaveBeenCalledWith(
orgIdentifier,
+45 -16
View File
@@ -76,8 +76,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
</span>
<OTooltip
v-if="!queryLoading && (runPending || isAutoRunOn)"
:content="runPending ? t('search.autoRunPendingDot') : t('search.autoRunEnabled')"
v-if="!queryLoading && (!canQuery || runPending || isAutoRunOn)"
:content="runButtonTooltip"
/>
</OButton>
<ODropdown
@@ -892,6 +892,8 @@ 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;
// The focus from a link belongs to the first run's tree; any later run draws a tree its index path may not match.
let keepSeededFocus = false;
// Set while a watcher reloads meta, which can pick a new service or type: the select watcher must not run a second query.
let reloadingMeta = false;
// Set by a drag on the chart: that is a direct action on the results, so it runs even with Auto Run off.
@@ -939,6 +941,10 @@ const currentProfileType = computed(() => {
const canQuery = computed(
() => !!orgIdentifier.value && !!selectedStream.value && !!currentProfileType.value,
);
const runButtonTooltip = computed(() => {
if (!canQuery.value) return t("profiles.runNeedsProfileType");
return runPending.value ? t("search.autoRunPendingDot") : t("search.autoRunEnabled");
});
const hasProfileData = computed(() => (mergeResult.value?.total ?? 0) > 0);
// The server keeps the most common stacks only; say how much of the data the views show.
const sampledPercent = computed(() => {
@@ -1011,6 +1017,7 @@ const seriesChartData = computed(() => {
line.points.map((point) => [Math.floor(point.timestamp / 1000), seriesDisplayValue(point)]),
);
const seriesData = lineData.flat();
const runRange = lastRun.value?.range;
return {
options: {
// Tooltip, grid and y axis follow the Logs histogram (utils/logs/convertLogData.ts).
@@ -1034,7 +1041,13 @@ const seriesChartData = computed(() => {
dataZoom: { brushStyle: dataZoomBrushStyle(), show: true, yAxisIndex: "none" },
},
},
xAxis: { type: "time", axisLabel: { hideOverlap: true } },
// The axis spans the run's window, so one bucket is not stretched to fill the chart.
xAxis: {
type: "time",
min: runRange ? Math.floor(runRange.startTime / 1000) : undefined,
max: runRange ? Math.floor(runRange.endTime / 1000) : undefined,
axisLabel: { hideOverlap: true },
},
yAxis: {
type: "value",
axisLine: { show: true },
@@ -1053,7 +1066,8 @@ const seriesChartData = computed(() => {
type: "line",
name: line.label,
smooth: true,
showSymbol: false,
// With symbols off, a lone point draws nothing, and two read as a bare segment.
showSymbol: line.points.length < 3,
color: line.color || undefined,
lineStyle: { width: 2 },
// An area under one line reads as volume; under several it hides the lines below.
@@ -1411,6 +1425,7 @@ const resetProfilesState = () => {
sandwichFunction.value = null;
detailsName.value = null;
flameFocus.value = null;
keepSeededFocus = false;
errorMessage.value = "";
};
@@ -1558,26 +1573,33 @@ const loadMeta = async () => {
if (!org || !stream || !range) return;
const requestSeq = ++metaRequestSeq;
const run = initRunSeq;
try {
const response = await profilesService.meta(org, stream, {
const isStale = () => requestSeq !== metaRequestSeq || isStaleRun(run, org);
// The server scopes only profile_types to service_name; services always lists every service.
const fetchMeta = (service: string | null) =>
profilesService.meta(org, stream, {
start_time: range.startTime,
end_time: range.endTime,
...(service ? { service_name: service } : {}),
});
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
try {
let service = selectedService.value;
let response = await fetchMeta(service);
if (isStale()) return;
const services: string[] = response.data.services ?? [];
// An empty window lists no services; keep the chosen one for when data comes back.
if (services.length && (!service || !services.includes(service))) {
service = services[0];
response = await fetchMeta(service);
if (isStale()) return;
}
metaResponse.value = response.data;
const firstService = serviceOptions.value[0]?.value ?? null;
// CPU first, as the Traces "View Profile" link does; the server lists types alphabetically (alloc_objects first).
const firstProfileType =
profileTypeOptions.value.find((option) => option.value.startsWith("cpu\u0000"))?.value ??
profileTypeOptions.value[0]?.value ??
null;
if (
!selectedService.value ||
!serviceOptions.value.some((item) => item.value === selectedService.value)
) {
selectedService.value = firstService;
}
selectedService.value = service;
if (
!selectedProfileType.value ||
!profileTypeOptions.value.some((item) => item.value === selectedProfileType.value)
@@ -1597,7 +1619,7 @@ const loadMeta = async () => {
breakdownLabel.value = null;
}
} catch (error) {
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
if (isStale()) return;
errorMessage.value = buildErrorMessage(error);
}
};
@@ -1640,6 +1662,9 @@ const runQuery = async () => {
const range = resolveTimeRange();
const payload = queryPayload(range);
if (!org || !stream || !payload || !range) return;
// Only the run started with the link's inputs may keep its focus, even if it is superseded or fails.
const keepFocus = keepSeededFocus;
keepSeededFocus = false;
const runState = { query: pageQuery(), range, payload };
const requestSeq = ++queryRequestSeq;
const run = initRunSeq;
@@ -1660,6 +1685,7 @@ const runQuery = async () => {
if (requestSeq !== queryRequestSeq || isStaleRun(run, org)) return;
seriesResponse.value = seriesResult.data;
mergeResult.value = mergeResponseValue.data;
if (!keepFocus) flameFocus.value = null;
lastRun.value = runState;
resetStackExpansion(mergeResponseValue.data.root);
syncUrl();
@@ -1862,6 +1888,7 @@ const initPage = async () => {
sandwichFunction.value = seed.sandwichFunction;
detailsName.value = seed.details;
flameFocus.value = seed.focus;
keepSeededFocus = !!seed.focus;
breakdownLabel.value = seed.breakdown;
compareState.value = seed.compare;
}
@@ -1896,8 +1923,10 @@ const reloadMeta = async () => {
};
// A new service or profile type runs the query (Auto Run) or marks Run query pending.
watch([selectedService, selectedProfileType], async () => {
watch([selectedService, selectedProfileType], async ([service], [previousService]) => {
if (seeding || reloadingMeta) return;
// Profile types are scoped to the service; reloadMeta holds this watcher, so it cannot loop.
if (service !== previousService) await reloadMeta();
if (draftTagKey.value) {
draftTagValue.value = null;
await loadTagValues();
+3 -3
View File
@@ -129,7 +129,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButtonGroup
v-if="showPinnedSavedViews"
data-test="traces-search-bar-saved-views"
class="element-box-shadow border-button-outline-border shrink-0 border p-0"
class="border-button-outline-border shrink-0 border p-0"
>
<ODropdown
:open="savedViewsDropdownOpen"
@@ -328,7 +328,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="ghost"
data-test="traces-search-bar-cancel-btn"
:title="t('search.cancel')"
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow h-[1.875rem]! w-[5.875rem]! ![border-radius:0.375rem_0_0_0.375rem] p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease]"
class="bg-cancel-query-bg! text-button-primary-foreground! h-[1.875rem]! w-[5.875rem]! ![border-radius:0.375rem_0_0_0.375rem] p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease]"
@click="cancelQueryData"
>{{ t("search.cancel") }}</OButton
>
@@ -338,7 +338,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="logs-search-bar-refresh-btn"
data-cy="search-bar-refresh-button"
:title="t('search.runQuery')"
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:ring-button-primary/70 h-[1.875rem]! w-[5.875rem]! p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease] hover:opacity-90 hover:ring-2"
class="bg-button-primary! text-button-primary-foreground! hover:ring-button-primary/70 h-[1.875rem]! w-[5.875rem]! p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease] hover:opacity-90 hover:ring-2"
:class="
store.state.zoConfig.auto_query_enabled
? '![border-radius:0.375rem_0_0_0.375rem]'
+107
View File
@@ -0,0 +1,107 @@
// 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 { beforeEach, describe, expect, it, vi } from "vitest";
const rumInit = vi.hoisted(() => vi.fn());
const rumSdk = vi.hoisted(() => ({
setUser: vi.fn(),
startSessionReplayRecording: vi.fn(),
}));
const logsInit = vi.hoisted(() => vi.fn());
const getConfigFull = vi.hoisted(() => vi.fn());
vi.mock("@openobserve/browser-rum", () => ({ openobserveRum: { init: rumInit, ...rumSdk } }));
vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } }));
vi.mock("@/services/config", () => ({ default: { get_config_full: getConfigFull } }));
import { queryClient } from "@/composables/query/queryClient";
import store from "@/stores";
import { resetBrowserRumForTests } from "./initBrowserRum";
import { ensureBrowserRum } from "./ensureBrowserRum";
const RUM = {
enabled: true,
client_token: "token",
application_id: "app",
site: "rum.example.com",
service: "web",
env: "test",
organization_identifier: "org",
};
const USER = { email: "user@example.com", given_name: "Ada", family_name: "Lovelace" };
beforeEach(() => {
resetBrowserRumForTests();
queryClient.clear();
rumInit.mockReset();
logsInit.mockReset();
Object.values(rumSdk).forEach((fn) => fn.mockReset());
store.state.userInfo = USER;
getConfigFull.mockReset();
getConfigFull.mockResolvedValue({ data: { version: "v1", rum: RUM } });
});
describe("ensureBrowserRum", () => {
it("starts RUM from the org's full config and identifies the user without replay", async () => {
await ensureBrowserRum("acme");
expect(getConfigFull).toHaveBeenCalledWith("acme");
expect(rumInit).toHaveBeenCalledTimes(1);
expect(logsInit).toHaveBeenCalledTimes(1);
expect(rumSdk.setUser).toHaveBeenCalledWith({
id: "user@example.com",
name: "Ada Lovelace",
email: "user@example.com",
});
expect(rumSdk.startSessionReplayRecording).not.toHaveBeenCalled();
});
it("does not fetch the config again once RUM has started", async () => {
await ensureBrowserRum("acme");
await ensureBrowserRum("beta");
expect(getConfigFull).toHaveBeenCalledTimes(1);
expect(rumInit).toHaveBeenCalledTimes(1);
});
it("does nothing without an org", async () => {
await ensureBrowserRum(undefined);
await ensureBrowserRum("");
expect(getConfigFull).not.toHaveBeenCalled();
expect(rumInit).not.toHaveBeenCalled();
});
it("neither starts RUM nor sets the user when the full config has it disabled", async () => {
getConfigFull.mockResolvedValue({ data: { version: "v1", rum: { ...RUM, enabled: false } } });
await ensureBrowserRum("acme");
expect(rumInit).not.toHaveBeenCalled();
expect(rumSdk.setUser).not.toHaveBeenCalled();
});
it("never rejects when the full config cannot be fetched", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
getConfigFull.mockRejectedValue(new Error("403"));
await expect(ensureBrowserRum("acme")).resolves.toBeUndefined();
expect(rumInit).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});
+41
View File
@@ -0,0 +1,41 @@
// 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 { openobserveRum } from "@openobserve/browser-rum";
import { queryClient } from "@/composables/query/queryClient";
import { configFullQuery } from "@/services/config.queries";
import store from "@/stores";
import { initBrowserRum, isBrowserRumStarted, type BrowserRumConfig } from "./initBrowserRum";
/** Starts RUM on a signed-in page outside MainLayout from the org's full config; never rejects. */
export const ensureBrowserRum = async (orgIdentifier: string | undefined): Promise<void> => {
if (!orgIdentifier || isBrowserRumStarted()) return;
try {
const fullConfig: { rum?: BrowserRumConfig } | undefined = await queryClient.fetchQuery(
configFullQuery(orgIdentifier),
);
if (!initBrowserRum(fullConfig?.rum, store.state.API_ENDPOINT)) return;
const userInfo: { email?: string; given_name?: string; family_name?: string } =
store.state.userInfo;
// Standalone pages never record replay; only MainLayout and sign-in start it.
openobserveRum.setUser({
id: userInfo.email,
name: userInfo.given_name + " " + userInfo.family_name,
email: userInfo.email,
});
} catch (error) {
console.error("Failed to start RUM:", error);
}
};
+269
View File
@@ -0,0 +1,269 @@
// 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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserRumConfig } from "./initBrowserRum";
const rumInit = vi.hoisted(() => vi.fn());
const logsInit = vi.hoisted(() => vi.fn());
vi.mock("@openobserve/browser-rum", () => ({
openobserveRum: { init: rumInit },
}));
vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } }));
const EXT = "chrome-extension://abcdefghijklmnop";
const RUM: BrowserRumConfig = {
enabled: true,
client_token: "token",
application_id: "app",
site: "rum.example.com",
service: "web",
env: "test",
version: "v0.70.0",
organization_identifier: "org",
api_version: "v1",
insecure_http: false,
};
// The SDK may be initialised once per page load, so each case gets a fresh module.
const loadHelper = async () => (await import("./initBrowserRum")).initBrowserRum;
const rumBeforeSend = (): ((event: Record<string, unknown>) => boolean) =>
rumInit.mock.calls[0][0].beforeSend;
const errorEvent = (stack: string) => ({
type: "error",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
error: { message: "boom", stack },
});
beforeEach(() => {
vi.resetModules();
rumInit.mockReset();
logsInit.mockReset();
});
describe("initBrowserRum", () => {
it("initialises the RUM and logs SDKs from the full config's rum block", async () => {
const initBrowserRum = await loadHelper();
expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true);
expect(rumInit).toHaveBeenCalledTimes(1);
expect(rumInit.mock.calls[0][0]).toMatchObject({
applicationId: "app",
clientToken: "token",
site: "rum.example.com",
organizationIdentifier: "org",
service: "web",
env: "test",
version: "v0.70.0",
apiVersion: "v1",
insecureHTTP: false,
startSessionReplayRecordingManually: true,
});
expect(logsInit).toHaveBeenCalledTimes(1);
expect(logsInit.mock.calls[0][0]).toMatchObject({
clientToken: "token",
site: "rum.example.com",
organizationIdentifier: "org",
version: "v0.70.0",
});
});
it("initialises only once when the full config arrives again for another org", async () => {
const initBrowserRum = await loadHelper();
expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true);
expect(initBrowserRum({ ...RUM }, "http://localhost:5080")).toBe(true);
expect(rumInit).toHaveBeenCalledTimes(1);
expect(logsInit).toHaveBeenCalledTimes(1);
});
it.each([
["absent", undefined],
["null", null],
["disabled", { ...RUM, enabled: false }],
])("does not initialise when rum is %s", async (_label, rum) => {
const initBrowserRum = await loadHelper();
expect(initBrowserRum(rum, "http://localhost:5080")).toBe(false);
expect(rumInit).not.toHaveBeenCalled();
expect(logsInit).not.toHaveBeenCalled();
});
it("never throws into the config path and still starts logs when the RUM SDK throws", async () => {
const initBrowserRum = await loadHelper();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
rumInit.mockImplementation(() => {
throw new Error("rum boom");
});
expect(() => initBrowserRum(RUM, "")).not.toThrow();
expect(logsInit).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
it("reports RUM off and retries it on the next config after the RUM SDK throws", async () => {
const initBrowserRum = await loadHelper();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
rumInit.mockImplementationOnce(() => {
throw new Error("rum boom");
});
expect(initBrowserRum(RUM, "")).toBe(false);
expect(initBrowserRum(RUM, "")).toBe(true);
expect(rumInit).toHaveBeenCalledTimes(2);
expect(logsInit).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
});
it.each([["client_token"], ["application_id"], ["site"]])(
"does not start RUM without %s and starts it from a corrected config",
async (field) => {
const initBrowserRum = await loadHelper();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(initBrowserRum({ ...RUM, [field]: "" }, "")).toBe(false);
expect(rumInit).not.toHaveBeenCalled();
expect(initBrowserRum(RUM, "")).toBe(true);
expect(rumInit).toHaveBeenCalledTimes(1);
expect(rumInit.mock.calls[0][0]).toMatchObject({
applicationId: "app",
clientToken: "token",
});
expect(logsInit).toHaveBeenCalledTimes(1);
consoleError.mockRestore();
},
);
});
describe("initBrowserRum beforeSend", () => {
beforeEach(async () => {
(await loadHelper())(RUM, "");
});
it("drops an error whose every frame is a Chrome extension", () => {
const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`;
expect(rumBeforeSend()(errorEvent(stack))).toBe(false);
});
it("sends an error with at least one app frame", () => {
const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at setup (https://cloud.openobserve.ai/web/assets/index.js:3:4)`;
expect(rumBeforeSend()(errorEvent(stack))).toBe(true);
});
it("drops an error matching an ignored pattern", () => {
const event = {
type: "error",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
error: { message: "ResizeObserver loop completed with undelivered notifications", stack: "" },
};
expect(rumBeforeSend()(event)).toBe(false);
});
it("still scrubs sign-in tokens from the view URL of an event it sends", () => {
const event = {
type: "view",
view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" },
};
expect(rumBeforeSend()(event)).toBe(true);
expect(event.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
});
it("drops a JWT from a click's target name", () => {
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln";
const event = {
type: "action",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
action: { target: { name: `Copy ${jwt}` } },
};
expect(rumBeforeSend()(event)).toBe(true);
expect(event.action.target.name).toBe("Copy ");
});
it("drops a log matching an ignored pattern and scrubs the view URL of one it sends", () => {
const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as (
log: Record<string, unknown>,
) => boolean;
expect(logsBeforeSend({ message: "ResizeObserver loop limit exceeded" })).toBe(false);
const log = {
message: "hello",
view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc" },
};
expect(logsBeforeSend(log)).toBe(true);
expect(log.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
});
it("scrubs a public dashboard slug from a resource URL", () => {
const event = {
type: "resource",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/panels" },
};
expect(rumBeforeSend()(event)).toBe(true);
expect(event.resource.url).toBe(
"https://cloud.openobserve.ai/api/public_dashboards/redacted/panels",
);
});
it("scrubs secrets from an error's message and resource URL", () => {
const event = {
type: "error",
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
error: {
message: "Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=abc",
stack: "",
resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" },
},
};
expect(rumBeforeSend()(event)).toBe(true);
expect(event.error.message).toBe(
"Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=redacted",
);
expect(event.error.resource.url).toBe(
"https://cloud.openobserve.ai/api/public_dashboards/redacted/data",
);
});
it("scrubs secrets from a log's referrer, HTTP URL and message", () => {
const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as (
log: Record<string, unknown>,
) => boolean;
const log = {
message: "GET https://cloud.openobserve.ai/web/public/dashboards/s3cr3t failed",
view: {
url: "https://cloud.openobserve.ai/web/logs",
referrer: "https://cloud.openobserve.ai/web/cb#id_token=abc",
},
http: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" },
};
expect(logsBeforeSend(log)).toBe(true);
expect(log.view.referrer).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
expect(log.http.url).toBe("https://cloud.openobserve.ai/api/public_dashboards/redacted/data");
expect(log.message).toBe(
"GET https://cloud.openobserve.ai/web/public/dashboards/redacted failed",
);
});
});
+184
View File
@@ -0,0 +1,184 @@
// 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 { openobserveRum } from "@openobserve/browser-rum";
import { openobserveLogs } from "@openobserve/browser-logs";
import { shouldPropagateTracing } from "./tracingOrigin";
import { isIgnoredNoise } from "./ignoredNoisePatterns";
import { scrubAuthParams } from "./scrubAuthParams";
import { isExtensionOnlyError } from "./extensionFrames";
/** The `rum` block of the authenticated full config (`GET /api/{org}/config`). */
export interface BrowserRumConfig {
enabled: boolean;
client_token: string;
application_id: string;
site: string;
service: string;
env: string;
version?: string;
organization_identifier: string;
api_version?: string;
insecure_http?: boolean;
}
// Only the three dot-separated parts make a token, so a bare eyJ inside a word (surveyJsonBtn) is kept.
const JWT_PATTERN = /eyJ[\w-]*\.[\w-]+\.[\w-]*/g;
const IGNORED_ERROR_PATTERNS = [
/ResizeObserver loop/i,
/reo.dev/i,
/Cannot set properties of null \(setting 'innerHTML'\)/,
];
const IGNORED_LOG_PATTERNS = [/ResizeObserver loop/i];
let rumStarted = false;
let logsStarted = false;
/** True once the RUM SDK has started in this page load. */
export const isBrowserRumStarted = (): boolean => rumStarted;
/** Starts the browser RUM and logs SDKs at most once per page load; returns whether RUM is on. Never throws. */
export const initBrowserRum = (
rum: BrowserRumConfig | null | undefined,
apiEndpoint: string,
): boolean => {
if (!rum?.enabled) return false;
if (rumStarted && logsStarted) return true;
const version = rum.version || "0.0.1";
const apiVersion = rum.api_version || "v1";
const insecureHTTP = rum.insecure_http || false;
// Without a token or application id the SDK drops the init, and without a site it sends to its own default intake.
const hasLogsSettings = Boolean(rum.client_token && rum.site);
const hasRumSettings = hasLogsSettings && Boolean(rum.application_id);
if (!rumStarted && hasRumSettings) {
rumStarted = tryInit("RUM", () =>
startRumSdk(rum, apiEndpoint, version, apiVersion, insecureHTTP),
);
} else if (!rumStarted) {
console.error("The RUM configuration lacks a client token, application id or site.");
}
if (!logsStarted && hasLogsSettings) {
logsStarted = tryInit("logs", () => startLogsSdk(rum, version, apiVersion, insecureHTTP));
}
return rumStarted;
};
export const resetBrowserRumForTests = (): void => {
rumStarted = false;
logsStarted = false;
};
const tryInit = (sdk: string, init: () => void): boolean => {
try {
init();
return true;
} catch (error) {
console.error(`Failed to start the browser ${sdk} SDK:`, error);
return false;
}
};
const startRumSdk = (
rum: BrowserRumConfig,
apiEndpoint: string,
version: string,
apiVersion: string,
insecureHTTP: boolean,
): void =>
openobserveRum.init({
applicationId: rum.application_id,
clientToken: rum.client_token,
site: rum.site,
organizationIdentifier: rum.organization_identifier,
service: rum.service,
env: rum.env,
version,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
actionNameAttribute: "data-test",
apiVersion,
insecureHTTP,
defaultPrivacyLevel: "allow",
// Session renewal drops the forced replay start, so sampling must keep every renewed session recording.
sessionReplaySampleRate: 100,
startSessionReplayRecordingManually: true,
// Cross-origin, the injected headers fail the CORS preflight and kill every API call.
allowedTracingUrls: shouldPropagateTracing(apiEndpoint, window.location.origin)
? [
{
match: apiEndpoint + "/api",
propagatorTypes: ["openobserve", "tracecontext"],
},
]
: [],
beforeSend: (event) => {
event.view.url = scrubAuthParams(event.view.url) ?? event.view.url;
event.view.referrer = scrubAuthParams(event.view.referrer);
if (event.type === "resource") {
event.resource.url = scrubAuthParams(event.resource.url) ?? event.resource.url;
}
if (event.type === "error") {
event.error.message = scrubAuthParams(event.error.message) ?? event.error.message;
if (event.error.resource) {
event.error.resource.url =
scrubAuthParams(event.error.resource.url) ?? event.error.resource.url;
}
}
if (event.type === "error" && isExtensionOnlyError(event.error?.stack)) return false;
if (event.type === "action" && event.action.target?.name) {
event.action.target.name = event.action.target.name.replace(JWT_PATTERN, "");
}
if (event.type === "error") {
const errorMessage = event.error?.message || "";
const errorStack = event.error?.stack || "";
const shouldIgnore = IGNORED_ERROR_PATTERNS.some(
(pattern) => pattern.test(errorMessage) || pattern.test(errorStack),
);
if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) return false;
}
return true;
},
});
const startLogsSdk = (
rum: BrowserRumConfig,
version: string,
apiVersion: string,
insecureHTTP: boolean,
): void =>
openobserveLogs.init({
clientToken: rum.client_token,
site: rum.site,
organizationIdentifier: rum.organization_identifier,
service: rum.service,
env: rum.env,
version,
forwardErrorsToLogs: true,
insecureHTTP,
apiVersion,
beforeSend: (log) => {
if (log.view?.url) log.view.url = scrubAuthParams(log.view.url) ?? log.view.url;
if (log.view?.referrer) log.view.referrer = scrubAuthParams(log.view.referrer);
if (log.http?.url) log.http.url = scrubAuthParams(log.http.url) ?? log.http.url;
log.message = scrubAuthParams(log.message) ?? log.message;
const logMessage = log.message || "";
const shouldIgnore = IGNORED_LOG_PATTERNS.some((pattern) => pattern.test(logMessage));
return !(shouldIgnore || isIgnoredNoise(logMessage));
},
});
@@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router";
import { createStore } from "vuex";
import organizationsService from "@/services/organizations";
import awsMarketplace from "@/services/awsMarketplace";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
// Mock the services the two card-forms drive on submit.
vi.mock("@/services/organizations", async (importOriginal) => {
@@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => {
},
});
});
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
vi.mock("@/services/awsMarketplace", () => ({
default: {
linkSubscription: vi.fn(),
@@ -273,6 +275,12 @@ describe("AwsMarketplaceSetup", () => {
return wrapper;
};
it("starts RUM from the first org the caller belongs to", async () => {
await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]);
expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1");
});
it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => {
const wrapper = await mountSelectOrg();
const createForm = wrapper.findAllComponents({ name: "OForm" })[0];
+3
View File
@@ -197,6 +197,7 @@ import { raw, useI18nTyped } from "@/types/i18n";
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
import awsMarketplace from "@/services/awsMarketplace";
import organizationsService from "@/services/organizations";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
@@ -265,6 +266,8 @@ export default defineComponent({
// Fetch user's organizations
await fetchOrganizations();
// RUM settings are instance-wide, so any org the user can read serves them.
void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier);
});
onUnmounted(() => {
@@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router";
import { createStore } from "vuex";
import organizationsService from "@/services/organizations";
import azureMarketplace from "@/services/azureMarketplace";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
// Mock the services the two card-forms drive on submit.
vi.mock("@/services/organizations", async (importOriginal) => {
@@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => {
},
});
});
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
vi.mock("@/services/azureMarketplace", () => ({
default: {
linkSubscription: vi.fn(),
@@ -259,6 +261,12 @@ describe("AzureMarketplaceSetup", () => {
return wrapper;
};
it("starts RUM from the first org the caller belongs to", async () => {
await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]);
expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1");
});
it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => {
const wrapper = await mountSelectOrg();
const createForm = wrapper.findAllComponents({ name: "OForm" })[0];
+3
View File
@@ -198,6 +198,7 @@ import { raw, useI18nTyped } from "@/types/i18n";
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
import azureMarketplace from "@/services/azureMarketplace";
import organizationsService from "@/services/organizations";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
@@ -247,6 +248,8 @@ export default defineComponent({
// Fetch user's organizations
await fetchOrganizations();
// RUM settings are instance-wide, so any org the user can read serves them.
void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier);
});
const fetchOrganizations = async () => {
@@ -22,10 +22,12 @@ import { makeSlackStore, ok } from "@/test/unit/helpers/slack";
import { isSlackOAuthCallbackMessage } from "@/utils/slackOAuth";
import { createStore } from "vuex";
import organizations from "@/services/organizations";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import SlackConnectCallback from "./SlackConnectCallback.vue";
vi.mock("@/services/slack", () => ({ default: { confirmConnection: vi.fn() } }));
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
const refusal = (status: number, data: Record<string, unknown> = {}) =>
Object.assign(new Error("refused"), {
@@ -70,6 +72,11 @@ afterEach(() => {
});
describe("SlackConnectCallback", () => {
it("starts RUM from the full config of the org this page resolved", async () => {
await mountCallback();
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
});
it("in a popup, posts {type, code, state} to its same-origin opener and closes", async () => {
const opener = { postMessage: vi.fn() };
setOpener(opener);
+2
View File
@@ -20,6 +20,7 @@ import { useMutation } from "@tanstack/vue-query";
import { useRoute, useRouter } from "vue-router";
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import OButton from "@/lib/core/Button/OButton.vue";
import type { IllustrationName } from "@/lib/core/EmptyState/illustrations";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
@@ -128,6 +129,7 @@ const openSettings = (): void => {
};
onMounted(() => {
whenResolved(() => void ensureBrowserRum(org.value));
const error = queryString(route.query.error);
const code = queryString(route.query.code);
const state = queryString(route.query.state);
+7
View File
@@ -22,12 +22,14 @@ import organizations from "@/services/organizations";
import slack from "@/services/slack";
import { leaveFor, recallLinkRequest } from "@/utils/slackLink";
import { axiosError, makeSlackStore, ok } from "@/test/unit/helpers/slack";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import SlackLink from "./SlackLink.vue";
vi.mock("@/services/slack", () => ({
default: { startLinkRequest: vi.fn(), resendLinkRequest: vi.fn() },
}));
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
vi.mock("@/utils/slackLink", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/slackLink")>()),
leaveFor: vi.fn(),
@@ -77,6 +79,11 @@ afterEach(() => {
});
describe("SlackLink (N7 pass-through)", () => {
it("starts RUM from the full config of the org this page resolved", async () => {
await mountLink();
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
});
it("starts the request with the signature from ?r and hands off to Sign in with Slack", async () => {
const w = await mountLink();
+5 -1
View File
@@ -21,6 +21,7 @@ import { useRoute } from "vue-router";
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
import SlackLinkRefusal from "@/components/slack/SlackLinkRefusal.vue";
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import { resendLinkRequestMutation, startLinkRequestMutation } from "@/services/slack.queries";
import { useI18nTyped } from "@/types/i18n";
@@ -89,7 +90,10 @@ const sendNewLink = async (): Promise<void> => {
}
};
onMounted(() => whenResolved(begin));
onMounted(() => {
whenResolved(() => void ensureBrowserRum(org.value));
whenResolved(begin);
});
</script>
<template>
+7
View File
@@ -22,12 +22,14 @@ import { leaveFor, recallLinkRequest, rememberLinkRequest } from "@/utils/slackL
import { makeSlackStore, ok } from "@/test/unit/helpers/slack";
import { createStore } from "vuex";
import organizations from "@/services/organizations";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import SlackLinkCallback from "./SlackLinkCallback.vue";
vi.mock("@/services/slack", () => ({
default: { confirmLinkRequest: vi.fn(), resendLinkRequest: vi.fn() },
}));
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
vi.mock("@/utils/slackLink", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/slackLink")>()),
leaveFor: vi.fn(),
@@ -88,6 +90,11 @@ afterEach(() => {
});
describe("SlackLinkCallback", () => {
it("starts RUM from the full config of the org this page resolved", async () => {
await mountCallback();
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
});
it("confirms the code, shows Linked as, then strips the query and forgets the request", async () => {
rememberLinkRequest("req-1.sig", "#help");
const replaceState = vi.spyOn(window.history, "replaceState");
+2
View File
@@ -21,6 +21,7 @@ import { useRoute, useRouter } from "vue-router";
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
import SlackLinkRefusal from "@/components/slack/SlackLinkRefusal.vue";
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import { confirmLinkRequestMutation, resendLinkRequestMutation } from "@/services/slack.queries";
@@ -114,6 +115,7 @@ const sendNewLink = async (): Promise<void> => {
};
onMounted(() => {
whenResolved(() => void ensureBrowserRum(org.value));
const code = queryString(route.query.code);
const state = queryString(route.query.state);
if (queryString(route.query.error)) {