#![allow(dead_code)] use std::collections::BTreeMap; use std::collections::hash_map::DefaultHasher; use std::fmt; use std::fs; use std::hash::{Hash, Hasher}; use std::io; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::config::Settings; use crate::consts::{DEFAULT_PROFILE_NAME, SEEDED_METRIC_LABEL, SEEDED_METRIC_NAME}; use crate::engine::data_value::MetricPrimitiveKind; use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage}; use tokio::sync::oneshot; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileMetricPrimitiveKind { String, Number, Timestamp, Duration, Boolean, } impl From for ProfileMetricPrimitiveKind { fn from(kind: MetricPrimitiveKind) -> Self { match kind { MetricPrimitiveKind::String => ProfileMetricPrimitiveKind::String, MetricPrimitiveKind::Number => ProfileMetricPrimitiveKind::Number, MetricPrimitiveKind::Timestamp => ProfileMetricPrimitiveKind::Timestamp, MetricPrimitiveKind::Duration => ProfileMetricPrimitiveKind::Duration, MetricPrimitiveKind::Boolean => ProfileMetricPrimitiveKind::Boolean, } } } impl From for MetricPrimitiveKind { fn from(kind: ProfileMetricPrimitiveKind) -> Self { match kind { ProfileMetricPrimitiveKind::String => MetricPrimitiveKind::String, ProfileMetricPrimitiveKind::Number => MetricPrimitiveKind::Number, ProfileMetricPrimitiveKind::Timestamp => MetricPrimitiveKind::Timestamp, ProfileMetricPrimitiveKind::Duration => MetricPrimitiveKind::Duration, ProfileMetricPrimitiveKind::Boolean => MetricPrimitiveKind::Boolean, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ParamDeclarationRecord { pub name: String, pub message_match_regex: String, pub capture_group: String, #[serde(default)] pub json_field_path: Option, pub kind: ProfileMetricPrimitiveKind, #[serde(default)] pub elapsed_since_last: bool, } impl From<&DeclareParamRequest> for ParamDeclarationRecord { fn from(request: &DeclareParamRequest) -> Self { ParamDeclarationRecord { name: request.name.clone(), message_match_regex: request.message_match_regex.clone(), capture_group: request.capture_group.clone(), json_field_path: request.json_field_path.clone(), kind: request.kind.into(), elapsed_since_last: request.elapsed_since_last, } } } impl From<&ParamDeclarationRecord> for DeclareParamRequest { fn from(record: &ParamDeclarationRecord) -> Self { DeclareParamRequest { name: record.name.clone(), message_match_regex: record.message_match_regex.clone(), capture_group: record.capture_group.clone(), json_field_path: record.json_field_path.clone(), kind: record.kind.into(), elapsed_since_last: record.elapsed_since_last, auto_derived: false, log_stream_gated: false, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] enum LegacyParamRole { #[default] Label, Value, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct LegacyMetricParamRecord { param_name: String, capture_group: String, #[serde(default)] json_field_path: Option, kind: ProfileMetricPrimitiveKind, #[serde(default)] role: LegacyParamRole, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MetricDeclarationRecord { pub name: String, pub message_match_regex: String, #[serde(default)] pub label_params: Vec, #[serde(default)] pub value_param: Option, #[serde(default)] pub matched_labels: Option>, #[serde(default)] pub unmatched_labels: Option>, #[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")] legacy_params: Vec, } impl From<&DeclareMetricRequest> for MetricDeclarationRecord { fn from(request: &DeclareMetricRequest) -> Self { MetricDeclarationRecord { name: request.name.clone(), message_match_regex: request.message_match_regex.clone(), label_params: request.label_params.clone(), value_param: request.value_param.clone(), matched_labels: request.matched_labels.clone(), unmatched_labels: request.unmatched_labels.clone(), legacy_params: Vec::new(), } } } impl From<&MetricDeclarationRecord> for DeclareMetricRequest { fn from(record: &MetricDeclarationRecord) -> Self { DeclareMetricRequest { name: record.name.clone(), message_match_regex: record.message_match_regex.clone(), label_params: record.label_params.clone(), value_param: record.value_param.clone(), matched_labels: record.matched_labels.clone(), unmatched_labels: record.unmatched_labels.clone(), } } } impl Profile { fn migrate_legacy_metric_params(&mut self) -> bool { let mut migrated = false; for metric_index in 0..self.metrics.len() { if self.metrics[metric_index].legacy_params.is_empty() { continue; } let metric_name = self.metrics[metric_index].name.clone(); let message_match_regex = self.metrics[metric_index].message_match_regex.clone(); let legacy = std::mem::take(&mut self.metrics[metric_index].legacy_params); let mut label_params = Vec::new(); let mut value_param = None; for matcher in &legacy { let param_name = self.resolve_or_create_legacy_param( &metric_name, &message_match_regex, matcher, ); match matcher.role { LegacyParamRole::Label => label_params.push(param_name), LegacyParamRole::Value => value_param = Some(param_name), } } self.metrics[metric_index].label_params = label_params; self.metrics[metric_index].value_param = value_param; migrated = true; } migrated } fn resolve_or_create_legacy_param( &mut self, metric_name: &str, message_match_regex: &str, matcher: &LegacyMetricParamRecord, ) -> String { if let Some(existing) = self.params.iter().find(|p| p.name == matcher.param_name) && existing.message_match_regex == message_match_regex && existing.capture_group == matcher.capture_group && existing.json_field_path == matcher.json_field_path && existing.kind == matcher.kind { return existing.name.clone(); } let implicit_name = format!("{metric_name}.{}", matcher.param_name); if !self.params.iter().any(|p| p.name == implicit_name) { self.params.push(ParamDeclarationRecord { name: implicit_name.clone(), message_match_regex: message_match_regex.to_string(), capture_group: matcher.capture_group.clone(), json_field_path: matcher.json_field_path.clone(), kind: matcher.kind, elapsed_since_last: matcher.role == LegacyParamRole::Value && matcher.kind == ProfileMetricPrimitiveKind::Duration, }); } implicit_name } fn migrate_duration_value_params(&mut self) -> bool { let value_param_names: std::collections::HashSet<&str> = self .metrics .iter() .filter_map(|metric| metric.value_param.as_deref()) .collect(); let mut migrated = false; for param in &mut self.params { if !param.elapsed_since_last && param.kind == ProfileMetricPrimitiveKind::Duration && value_param_names.contains(param.name.as_str()) { param.elapsed_since_last = true; migrated = true; } } migrated } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ChartType { Bar, Line, Pie, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Panel { pub id: String, pub title: String, pub promql: String, pub chart_type: ChartType, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Dashboard { pub id: String, pub name: String, #[serde(default)] pub panels: Vec, } fn generate_id(seed: &str) -> String { let mut hasher = DefaultHasher::new(); seed.hash(&mut hasher); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); nanos.hash(&mut hasher); format!("{:016x}", hasher.finish()) } impl Dashboard { pub fn new(name: String) -> Dashboard { let id = generate_id(&name); Dashboard { id, name, panels: Vec::new(), } } } impl MetricDeclarationRecord { fn seeded_error_split() -> MetricDeclarationRecord { MetricDeclarationRecord { name: SEEDED_METRIC_NAME.to_string(), message_match_regex: r"(?i)error".to_string(), label_params: Vec::new(), value_param: None, matched_labels: Some(BTreeMap::from([( SEEDED_METRIC_LABEL.to_string(), "error".to_string(), )])), unmatched_labels: Some(BTreeMap::from([( SEEDED_METRIC_LABEL.to_string(), "other".to_string(), )])), legacy_params: Vec::new(), } } } impl Dashboard { fn seeded_default() -> Dashboard { let mut dashboard = Dashboard::new("Overview".to_string()); dashboard.panels = vec![ Panel { id: generate_id("error-volume-line"), title: "Error vs. non-error volume".to_string(), promql: format!("sum(rate({SEEDED_METRIC_NAME}[1m])) by ({SEEDED_METRIC_LABEL})"), chart_type: ChartType::Line, }, Panel { id: generate_id("error-volume-pie"), title: "Error vs. non-error ratio".to_string(), promql: SEEDED_METRIC_NAME.to_string(), chart_type: ChartType::Pie, }, ]; dashboard } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Profile { pub name: String, #[serde(default)] pub params: Vec, #[serde(default)] pub metrics: Vec, #[serde(default)] pub dashboards: Vec, } impl Profile { pub fn new(name: String) -> Profile { Profile { name, params: Vec::new(), metrics: Vec::new(), dashboards: Vec::new(), } } } impl Profile { pub async fn load_into_engine(&self, engine: &EngineHandle) { for record in &self.params { let (respond_to, response) = oneshot::channel(); let request: DeclareParamRequest = record.into(); let name = request.name.clone(); if engine .tx .send(EngineMessage::DeclareParam { request, respond_to, }) .await .is_err() { eprintln!( "warning: engine unavailable while loading profile '{}'", self.name ); return; } match response.await { Ok(Ok(())) => {} Ok(Err(err)) => eprintln!( "warning: profile '{}' param declaration '{name}' rejected by engine: {err}", self.name ), Err(_) => eprintln!( "warning: engine dropped response while declaring param '{name}' from profile '{}'", self.name ), } } for record in &self.metrics { let (respond_to, response) = oneshot::channel(); let request: DeclareMetricRequest = record.into(); let name = request.name.clone(); if engine .tx .send(EngineMessage::DeclareMetric { request, respond_to, }) .await .is_err() { eprintln!( "warning: engine unavailable while loading profile '{}'", self.name ); return; } match response.await { Ok(Ok(())) => {} Ok(Err(err)) => eprintln!( "warning: profile '{}' metric declaration '{name}' rejected by engine: {err}", self.name ), Err(_) => eprintln!( "warning: engine dropped response while declaring metric '{name}' from profile '{}'", self.name ), } } } } #[derive(Debug)] pub enum ProfileError { NotFound, DuplicateName, CannotDeleteDefault, Io(io::Error), } impl fmt::Display for ProfileError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ProfileError::NotFound => write!(f, "no profile with that name"), ProfileError::DuplicateName => write!(f, "a profile with that name already exists"), ProfileError::CannotDeleteDefault => write!(f, "the Default profile cannot be deleted"), ProfileError::Io(err) => write!(f, "I/O error: {err}"), } } } impl std::error::Error for ProfileError {} impl From for ProfileError { fn from(err: io::Error) -> Self { ProfileError::Io(err) } } impl Profile { pub(crate) fn load_legacy_store(path: &Path) -> Option> { let contents = fs::read_to_string(path).ok()?; match serde_json::from_str::>(&contents) { Ok(profiles) => Some(profiles), Err(err) => { eprintln!( "warning: failed to parse legacy profile store at {}: {err}; skipping migration", path.display() ); None } } } } pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool { let mut any_migrated = false; for profile in profiles { let legacy_migrated = profile.migrate_legacy_metric_params(); let duration_migrated = profile.migrate_duration_value_params(); if legacy_migrated || duration_migrated { any_migrated = true; } } any_migrated } impl Settings { pub fn profile(&self, name: &str) -> Option<&Profile> { self.profiles.iter().find(|p| p.name == name) } pub fn create_profile( &mut self, settings_path: &Path, name: String, ) -> Result<(), ProfileError> { if self.profile(&name).is_some() { return Err(ProfileError::DuplicateName); } let mut profile = Profile::new(name.clone()); if let Some(active) = self.active_profile.as_deref().and_then(|n| self.profile(n)) { profile.params = active.params.clone(); profile.metrics = active.metrics.clone(); profile.dashboards = active.dashboards.clone(); } self.profiles.push(profile); self.save(settings_path)?; Ok(()) } pub fn delete_profile(&mut self, settings_path: &Path, name: &str) -> Result<(), ProfileError> { if name == DEFAULT_PROFILE_NAME { return Err(ProfileError::CannotDeleteDefault); } let index = self .profiles .iter() .position(|p| p.name == name) .ok_or(ProfileError::NotFound)?; self.profiles.remove(index); if self.active_profile.as_deref() == Some(name) { self.active_profile = None; } self.save(settings_path)?; Ok(()) } pub fn ensure_default_profile(&mut self, settings_path: &Path) -> Result<(), ProfileError> { if self.profile(DEFAULT_PROFILE_NAME).is_none() { let mut profile = Profile::new(DEFAULT_PROFILE_NAME.to_string()); profile .metrics .push(MetricDeclarationRecord::seeded_error_split()); profile.dashboards.push(Dashboard::seeded_default()); self.profiles.push(profile); } if self.active_profile.is_none() { self.active_profile = Some(DEFAULT_PROFILE_NAME.to_string()); } self.save(settings_path)?; Ok(()) } pub fn set_active_profile( &mut self, settings_path: &Path, name: Option, ) -> Result<(), ProfileError> { if let Some(name) = &name && self.profile(name).is_none() { return Err(ProfileError::NotFound); } self.active_profile = name; self.save(settings_path)?; Ok(()) } pub fn clear_param_declarations( &mut self, settings_path: &Path, profile_name: &str, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; profile.params.clear(); self.save(settings_path)?; Ok(()) } pub fn record_param_declaration( &mut self, settings_path: &Path, profile_name: &str, request: &DeclareParamRequest, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let record: ParamDeclarationRecord = request.into(); match profile.params.iter_mut().find(|r| r.name == record.name) { Some(existing) => *existing = record, None => profile.params.push(record), } self.save(settings_path)?; Ok(()) } pub fn record_metric_declaration( &mut self, settings_path: &Path, profile_name: &str, request: &DeclareMetricRequest, promoted_params: &[ParamDeclarationRecord], ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; for param in promoted_params { if !profile.params.iter().any(|p| p.name == param.name) { profile.params.push(param.clone()); } } let record: MetricDeclarationRecord = request.into(); match profile.metrics.iter_mut().find(|r| r.name == record.name) { Some(existing) => *existing = record, None => profile.metrics.push(record), } self.save(settings_path)?; Ok(()) } pub fn record_metric_declaration_update( &mut self, settings_path: &Path, profile_name: &str, old_name: &str, request: &DeclareMetricRequest, promoted_params: &[ParamDeclarationRecord], ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; if old_name != request.name { profile.metrics.retain(|r| r.name != old_name); } for param in promoted_params { if !profile.params.iter().any(|p| p.name == param.name) { profile.params.push(param.clone()); } } let record: MetricDeclarationRecord = request.into(); match profile.metrics.iter_mut().find(|r| r.name == record.name) { Some(existing) => *existing = record, None => profile.metrics.push(record), } self.save(settings_path)?; Ok(()) } pub fn remove_metric_declaration( &mut self, settings_path: &Path, profile_name: &str, name: &str, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; profile.metrics.retain(|r| r.name != name); self.save(settings_path)?; Ok(()) } pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> { let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?; Ok(&profile.dashboards) } pub fn create_dashboard( &mut self, settings_path: &Path, profile_name: &str, name: String, ) -> Result { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let dashboard = Dashboard::new(name); profile.dashboards.push(dashboard.clone()); self.save(settings_path)?; Ok(dashboard) } pub fn save_dashboard( &mut self, settings_path: &Path, profile_name: &str, dashboard_id: &str, mut dashboard: Dashboard, ) -> Result { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let existing = profile .dashboards .iter_mut() .find(|d| d.id == dashboard_id) .ok_or(ProfileError::NotFound)?; dashboard.id = dashboard_id.to_string(); *existing = dashboard.clone(); self.save(settings_path)?; Ok(dashboard) } pub fn delete_dashboard( &mut self, settings_path: &Path, profile_name: &str, dashboard_id: &str, ) -> Result<(), ProfileError> { let profile = self .profiles .iter_mut() .find(|p| p.name == profile_name) .ok_or(ProfileError::NotFound)?; let index = profile .dashboards .iter() .position(|d| d.id == dashboard_id) .ok_or(ProfileError::NotFound)?; profile.dashboards.remove(index); self.save(settings_path)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::api::dto::LogsParams; use crate::engine::data_value::MetricPrimitiveKind; use crate::engine::timestamp::Timestamp; use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine}; fn param_request(name: &str, pattern: &str, capture_group: &str) -> DeclareParamRequest { DeclareParamRequest { name: name.to_string(), message_match_regex: pattern.to_string(), capture_group: capture_group.to_string(), json_field_path: None, kind: MetricPrimitiveKind::String, elapsed_since_last: false, auto_derived: false, log_stream_gated: false, } } fn metric_request(name: &str, pattern: &str, param_name: &str) -> DeclareMetricRequest { DeclareMetricRequest { name: name.to_string(), message_match_regex: pattern.to_string(), matched_labels: None, unmatched_labels: None, label_params: vec![param_name.to_string()], value_param: None, } } async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse { let read_handle = engine.reader.handle(); let guard = read_handle.enter().expect("engine unavailable"); guard.handle_query_ro(query) } #[test] fn create_list_get_delete_round_trip() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .create_profile(&settings_path, "worker".to_string()) .unwrap(); assert_eq!(settings.profiles.len(), 2); assert!(settings.profile("web").is_some()); let dup = settings.create_profile(&settings_path, "web".to_string()); assert!(matches!(dup, Err(ProfileError::DuplicateName))); let reloaded_settings = Settings::load(&settings_path); assert_eq!(reloaded_settings.profiles.len(), 2); assert!(reloaded_settings.profile("web").is_some()); assert!(reloaded_settings.profile("worker").is_some()); settings.delete_profile(&settings_path, "worker").unwrap(); assert!(settings.profile("worker").is_none()); assert_eq!(settings.profiles.len(), 1); let missing = settings.delete_profile(&settings_path, "worker"); assert!(matches!(missing, Err(ProfileError::NotFound))); } #[test] fn ensure_default_creates_default_and_activates_it_once() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings.ensure_default_profile(&settings_path).unwrap(); assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some()); assert_eq!( settings.active_profile, Some(DEFAULT_PROFILE_NAME.to_string()) ); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .set_active_profile(&settings_path, Some("web".to_string())) .unwrap(); settings.ensure_default_profile(&settings_path).unwrap(); assert_eq!( settings .profiles .iter() .filter(|p| p.name == DEFAULT_PROFILE_NAME) .count(), 1 ); assert_eq!(settings.active_profile, Some("web".to_string())); } #[test] fn ensure_default_seeds_starter_dashboard_only_on_creation() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings.ensure_default_profile(&settings_path).unwrap(); let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap(); assert_eq!(default_profile.metrics.len(), 1); assert_eq!(default_profile.metrics[0].name, SEEDED_METRIC_NAME); assert_eq!( default_profile.metrics[0].matched_labels, Some(BTreeMap::from([( SEEDED_METRIC_LABEL.to_string(), "error".to_string() )])) ); assert_eq!( default_profile.metrics[0].unmatched_labels, Some(BTreeMap::from([( SEEDED_METRIC_LABEL.to_string(), "other".to_string() )])) ); assert_eq!(default_profile.dashboards.len(), 1); let dashboard = &default_profile.dashboards[0]; assert_eq!(dashboard.panels.len(), 2); assert!( dashboard .panels .iter() .any(|p| p.chart_type == ChartType::Line && p.promql.contains(SEEDED_METRIC_NAME)) ); assert!( dashboard .panels .iter() .any(|p| p.chart_type == ChartType::Pie && p.promql == SEEDED_METRIC_NAME) ); let mut reloaded = Settings::load(&settings_path); reloaded.ensure_default_profile(&settings_path).unwrap(); let default_profile = reloaded.profile(DEFAULT_PROFILE_NAME).unwrap(); assert_eq!(default_profile.metrics.len(), 1); assert_eq!(default_profile.dashboards.len(), 1); } #[tokio::test] async fn seeded_metric_renders_real_error_vs_other_data() { use crate::engine::timestamp::Timestamp; use crate::engine::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine}; let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings.ensure_default_profile(&settings_path).unwrap(); let default_profile = settings.profile(DEFAULT_PROFILE_NAME).unwrap().clone(); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let engine_tx = engine::spawn(settings_handle, 128); default_profile.load_into_engine(&engine_tx).await; for (sequence, text) in [ (0u64, "all good"), (1, "an ERROR happened"), (2, "fine again"), ] { engine_tx .tx .send(EngineMessage::Ingest(IngestedLine { sequence, timestamp: Timestamp::from_millis(sequence as i64), text: text.to_string(), })) .await .unwrap(); } engine_tx.barrier().await; let response = ask( &engine_tx, EngineQuery::Promql { query: SEEDED_METRIC_NAME.to_string(), start_ms: None, end_ms: None, }, ) .await; let EngineResponse::Promql(Ok(mut series)) = response else { panic!("expected a successful Promql response"); }; series.sort_by(|a, b| { a.labels .get(SEEDED_METRIC_LABEL) .cmp(&b.labels.get(SEEDED_METRIC_LABEL)) }); assert_eq!(series.len(), 2); assert_eq!( series[0] .labels .get(SEEDED_METRIC_LABEL) .map(String::as_str), Some("error") ); assert_eq!(series[0].points.len(), 1); assert_eq!( series[1] .labels .get(SEEDED_METRIC_LABEL) .map(String::as_str), Some("other") ); assert_eq!(series[1].points.len(), 2); } #[test] fn default_profile_cannot_be_deleted() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings.ensure_default_profile(&settings_path).unwrap(); let err = settings.delete_profile(&settings_path, DEFAULT_PROFILE_NAME); assert!(matches!(err, Err(ProfileError::CannotDeleteDefault))); assert!(settings.profile(DEFAULT_PROFILE_NAME).is_some()); } #[test] fn create_copies_the_active_profile() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); let param_req = param_request("level", r"level=(?P\w+)", "level"); settings .record_param_declaration(&settings_path, "web", ¶m_req) .unwrap(); settings .create_dashboard(&settings_path, "web", "Overview".to_string()) .unwrap(); settings .set_active_profile(&settings_path, Some("web".to_string())) .unwrap(); settings .create_profile(&settings_path, "web-copy".to_string()) .unwrap(); let copy = settings.profile("web-copy").unwrap().clone(); assert_eq!(copy.params, settings.profile("web").unwrap().params); assert_eq!(copy.dashboards, settings.profile("web").unwrap().dashboards); settings.set_active_profile(&settings_path, None).unwrap(); settings .create_profile(&settings_path, "blank".to_string()) .unwrap(); let blank = settings.profile("blank").unwrap(); assert!(blank.params.is_empty()); assert!(blank.dashboards.is_empty()); } #[test] fn set_active_updates_and_persists_settings() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .create_profile(&settings_path, "worker".to_string()) .unwrap(); settings .set_active_profile(&settings_path, Some("web".to_string())) .unwrap(); assert_eq!(settings.active_profile, Some("web".to_string())); settings .set_active_profile(&settings_path, Some("worker".to_string())) .unwrap(); let reloaded = Settings::load(&settings_path); assert_eq!(reloaded.active_profile, Some("worker".to_string())); let err = settings.set_active_profile(&settings_path, Some("ghost".to_string())); assert!(matches!(err, Err(ProfileError::NotFound))); assert_eq!(settings.active_profile, Some("worker".to_string())); settings.set_active_profile(&settings_path, None).unwrap(); assert_eq!(settings.active_profile, None); } #[test] fn record_param_and_metric_declarations_persist_into_profile() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); let param_req = param_request("level", r"level=(?P\w+)", "level"); settings .record_param_declaration(&settings_path, "web", ¶m_req) .unwrap(); let metric_req = metric_request("requests", r"duration=(?P\d+)", "duration"); settings .record_metric_declaration(&settings_path, "web", &metric_req, &[]) .unwrap(); let profile = settings.profile("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].name, "level"); assert_eq!(profile.metrics.len(), 1); assert_eq!(profile.metrics[0].name, "requests"); let param_req_v2 = param_request("level", r"lvl=(?P\w+)", "level"); settings .record_param_declaration(&settings_path, "web", ¶m_req_v2) .unwrap(); let profile = settings.profile("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].message_match_regex, r"lvl=(?P\w+)"); let reloaded = Settings::load(&settings_path); let profile = reloaded.profile("web").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.metrics.len(), 1); } #[test] fn remove_metric_declaration_drops_it_and_does_not_reappear_after_restart() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); let metric_req = metric_request("requests", r"duration=(?P\d+)", "duration"); settings .record_metric_declaration(&settings_path, "web", &metric_req, &[]) .unwrap(); assert_eq!(settings.profile("web").unwrap().metrics.len(), 1); settings .remove_metric_declaration(&settings_path, "web", "requests") .unwrap(); assert!(settings.profile("web").unwrap().metrics.is_empty()); settings .remove_metric_declaration(&settings_path, "web", "never-declared") .unwrap(); let reloaded = Settings::load(&settings_path); assert!(reloaded.profile("web").unwrap().metrics.is_empty()); } #[tokio::test] async fn profile_round_trip_reproduces_declarations_in_fresh_engine() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .record_param_declaration( &settings_path, "web", ¶m_request("level", r"level=(?P\w+)", "level"), ) .unwrap(); settings .record_param_declaration( &settings_path, "web", ¶m_request( "duration", r"request duration=(?P\d+)", "duration", ), ) .unwrap(); settings .record_metric_declaration( &settings_path, "web", &metric_request( "requests", r"request duration=(?P\d+)", "duration", ), &[], ) .unwrap(); settings .set_active_profile(&settings_path, Some("web".to_string())) .unwrap(); drop(settings); let settings = Settings::load(&settings_path); let active_name = settings.active_profile.clone().unwrap(); let profile = settings.profile(&active_name).unwrap().clone(); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let engine_tx = crate::engine::spawn(settings_handle, 16); profile.load_into_engine(&engine_tx).await; engine_tx .tx .send(EngineMessage::Ingest(IngestedLine { sequence: 0, timestamp: Timestamp::from_millis(0), text: "level=info request duration=42".to_string(), })) .await .unwrap(); engine_tx.barrier().await; let params = match ask(&engine_tx, EngineQuery::ParamDeclarations).await { EngineResponse::ParamDeclarations(params) => params, _ => panic!("unexpected response"), }; assert_eq!(params.len(), 2); let level = params.iter().find(|p| p.name == "level").unwrap(); assert_eq!(level.message_match_regex, r"level=(?P\w+)"); assert_eq!(level.capture_group, "level"); assert!(matches!(level.kind, MetricPrimitiveKind::String)); let metrics = match ask(&engine_tx, EngineQuery::MetricDeclarations).await { EngineResponse::MetricDeclarations(metrics) => metrics, _ => panic!("unexpected response"), }; assert_eq!(metrics.len(), 1); assert_eq!(metrics[0].name, "requests"); assert_eq!( metrics[0].message_match_regex, r"request duration=(?P\d+)" ); assert_eq!(metrics[0].label_params, vec!["duration".to_string()]); let logs = match ask( &engine_tx, EngineQuery::Logs(LogsParams { since_sequence: None, start_ms: None, end_ms: None, limit: None, filter: None, sort_by: None, sort_missing_fallback: None, }), ) .await { EngineResponse::Logs(logs) => logs, _ => panic!("unexpected response"), }; assert_eq!(logs.len(), 1); assert_eq!(logs[0].text, "level=info request duration=42"); } #[test] fn dashboard_create_save_delete_round_trip_and_persists_across_reload() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); let dashboard = settings .create_dashboard(&settings_path, "web", "Overview".to_string()) .unwrap(); assert_eq!(dashboard.name, "Overview"); assert!(dashboard.panels.is_empty()); let mut with_panel = dashboard.clone(); with_panel.panels.push(Panel { id: "panel-1".to_string(), title: "Requests".to_string(), promql: r#"requests{path="/a"}"#.to_string(), chart_type: ChartType::Line, }); let saved = settings .save_dashboard(&settings_path, "web", &dashboard.id, with_panel) .unwrap(); assert_eq!(saved.panels.len(), 1); assert_eq!(saved.id, dashboard.id); drop(settings); let mut reloaded = Settings::load(&settings_path); let dashboards = reloaded.list_dashboards("web").unwrap(); assert_eq!(dashboards.len(), 1); assert_eq!(dashboards[0].name, "Overview"); assert_eq!(dashboards[0].panels.len(), 1); assert_eq!(dashboards[0].panels[0].promql, r#"requests{path="/a"}"#); reloaded .delete_dashboard(&settings_path, "web", &dashboard.id) .unwrap(); assert!(reloaded.list_dashboards("web").unwrap().is_empty()); let reloaded_again = Settings::load(&settings_path); assert!(reloaded_again.list_dashboards("web").unwrap().is_empty()); assert!(matches!( reloaded_again.list_dashboards("ghost"), Err(ProfileError::NotFound) )); } fn write_legacy_settings_json(settings_path: &Path, profiles_json: &str) { fs::create_dir_all(settings_path.parent().unwrap()).unwrap(); let contents = format!( r#"{{"color_scheme":"system","css_entries":[],"custom_theme_id":null,"profiles":{profiles_json},"active_profile":null,"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}}"# ); fs::write(settings_path, contents).unwrap(); } #[test] fn migrates_legacy_inline_metric_params_into_standalone_declarations() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); write_legacy_settings_json( &settings_path, r#"[ { "name": "legacy", "params": [], "metrics": [ { "name": "requests", "message_match_regex": "request path=(?P\\S+) duration=(?P\\d+)", "params": [ { "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" }, { "param_name": "duration", "capture_group": "duration", "kind": "number", "role": "value" } ] } ], "dashboards": [] } ]"#, ); let settings = Settings::load(&settings_path); let profile = settings.profile("legacy").unwrap(); assert_eq!(profile.params.len(), 2); let path_param = profile .params .iter() .find(|p| p.name == "requests.path") .unwrap(); assert_eq!( path_param.message_match_regex, profile.metrics[0].message_match_regex ); assert_eq!(path_param.capture_group, "path"); assert!(matches!( path_param.kind, ProfileMetricPrimitiveKind::String )); let duration_param = profile .params .iter() .find(|p| p.name == "requests.duration") .unwrap(); assert!(matches!( duration_param.kind, ProfileMetricPrimitiveKind::Number )); assert_eq!(profile.metrics.len(), 1); assert_eq!( profile.metrics[0].label_params, vec!["requests.path".to_string()] ); assert_eq!( profile.metrics[0].value_param, Some("requests.duration".to_string()) ); let reloaded_raw = fs::read_to_string(&settings_path).unwrap(); assert!(!reloaded_raw.contains("param_name")); let profile = profile.clone(); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); tokio::runtime::Runtime::new().unwrap().block_on(async { let engine_tx = crate::engine::spawn(settings_handle, 16); profile.load_into_engine(&engine_tx).await; engine_tx .tx .send(EngineMessage::Ingest(IngestedLine { sequence: 0, timestamp: Timestamp::from_millis(0), text: "request path=/a duration=42".to_string(), })) .await .unwrap(); let EngineResponse::MetricDeclarations(metrics) = ask(&engine_tx, EngineQuery::MetricDeclarations).await else { panic!("unexpected response"); }; assert_eq!(metrics.len(), 1); assert_eq!(metrics[0].label_params, vec!["requests.path".to_string()]); }); } #[test] fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); write_legacy_settings_json( &settings_path, r#"[ { "name": "legacy", "params": [], "metrics": [ { "name": "login_gap", "message_match_regex": "user=(?P\\S+) logged in", "params": [ { "param_name": "user", "capture_group": "user", "kind": "string", "role": "label" }, { "param_name": "gap", "capture_group": "user", "kind": "duration", "role": "value" } ] } ], "dashboards": [] } ]"#, ); let settings = Settings::load(&settings_path); let profile = settings.profile("legacy").unwrap(); let gap_param = profile .params .iter() .find(|p| p.name == "login_gap.gap") .unwrap(); assert!(matches!( gap_param.kind, ProfileMetricPrimitiveKind::Duration )); assert!(gap_param.elapsed_since_last); assert_eq!( profile.metrics[0].value_param, Some("login_gap.gap".to_string()) ); } #[test] fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); write_legacy_settings_json( &settings_path, r#"[ { "name": "legacy", "params": [ { "name": "user", "message_match_regex": "user=(?P\\S+) logged in", "capture_group": "user", "kind": "string" }, { "name": "gap", "message_match_regex": "user=(?P\\S+) logged in", "capture_group": "user", "kind": "duration" } ], "metrics": [ { "name": "login_gap", "message_match_regex": "user=(?P\\S+) logged in", "label_params": ["user"], "value_param": "gap" } ], "dashboards": [] } ]"#, ); let settings = Settings::load(&settings_path); let profile = settings.profile("legacy").unwrap(); let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap(); assert!(gap_param.elapsed_since_last); assert!( !profile .params .iter() .find(|p| p.name == "user") .unwrap() .elapsed_since_last ); } #[test] fn migration_reuses_an_existing_matching_bare_named_param() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); write_legacy_settings_json( &settings_path, r#"[ { "name": "legacy", "params": [ { "name": "path", "message_match_regex": "request path=(?P\\S+)", "capture_group": "path", "kind": "string" } ], "metrics": [ { "name": "requests", "message_match_regex": "request path=(?P\\S+)", "params": [ { "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" } ] } ], "dashboards": [] } ]"#, ); let settings = Settings::load(&settings_path); let profile = settings.profile("legacy").unwrap(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].name, "path"); assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]); } #[test] fn migration_is_a_noop_on_an_already_migrated_profile() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .record_param_declaration( &settings_path, "web", ¶m_request("path", r"path=(?P\S+)", "path"), ) .unwrap(); settings .record_metric_declaration( &settings_path, "web", &metric_request("requests", r"path=(?P\S+)", "path"), &[], ) .unwrap(); let before = settings.profile("web").unwrap().clone(); let reloaded = Settings::load(&settings_path); let after = reloaded.profile("web").unwrap().clone(); assert_eq!(before, after); } #[tokio::test] async fn metric_referencing_auto_derived_param_survives_restart_via_promotion() { let dir = tempfile::tempdir().unwrap(); let settings_path = dir.path().join("settings.json"); let mut settings = Settings::default(); settings .create_profile(&settings_path, "web".to_string()) .unwrap(); settings .set_active_profile(&settings_path, Some("web".to_string())) .unwrap(); let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone())); let engine_tx = crate::engine::spawn(settings_handle, 16); engine_tx .tx .send(EngineMessage::Ingest(IngestedLine { sequence: 0, timestamp: Timestamp::from_millis(0), text: "dx=1 moved".to_string(), })) .await .unwrap(); let metric_req = metric_request("direction_x", r"dx=(?P\S+) moved", "dx"); let (respond_to, response) = oneshot::channel(); engine_tx .tx .send(EngineMessage::DeclareMetric { request: metric_req.clone(), respond_to, }) .await .unwrap(); response.await.unwrap().unwrap(); let EngineResponse::ParamDeclarations(live_params) = ask(&engine_tx, EngineQuery::ParamDeclarations).await else { panic!("unexpected response"); }; let dx = live_params.iter().find(|p| p.name == "dx").unwrap(); assert!(dx.auto_derived); let promoted = ParamDeclarationRecord { name: dx.name.clone(), message_match_regex: dx.message_match_regex.clone(), capture_group: dx.capture_group.clone(), json_field_path: dx.json_field_path.clone(), kind: dx.kind.into(), elapsed_since_last: dx.elapsed_since_last, }; settings .record_metric_declaration(&settings_path, "web", &metric_req, &[promoted]) .unwrap(); drop(settings); let reloaded_settings = Settings::load(&settings_path); let profile = reloaded_settings.profile("web").unwrap().clone(); assert_eq!(profile.params.len(), 1); assert_eq!(profile.params[0].name, "dx"); let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); let fresh_engine_tx = crate::engine::spawn(fresh_settings_handle, 16); profile.load_into_engine(&fresh_engine_tx).await; let EngineResponse::MetricDeclarations(metrics) = ask(&fresh_engine_tx, EngineQuery::MetricDeclarations).await else { panic!("unexpected response"); }; assert_eq!(metrics.len(), 1); assert_eq!(metrics[0].name, "direction_x"); } }