- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs. - src/cli/: argument parsing and process entry (formerly inline in main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now a thin entry point that just declares modules and calls cli::run(). - src/engine/: the actor + everything it depends on to parse/index/ query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs, rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest, filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs, timestamp.rs. - src/config/: persisted configuration: mod.rs (formerly settings.rs), profile.rs. - src/consts.rs stays at the crate root, used from every module above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
1358 lines
51 KiB
Rust
1358 lines
51 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::collections::BTreeMap;
|
|
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::consts::{DEFAULT_PROFILE_NAME, SEEDED_METRIC_LABEL, SEEDED_METRIC_NAME};
|
|
use crate::engine::data_value::MetricPrimitiveKind;
|
|
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
|
|
use crate::config::Settings;
|
|
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<MetricPrimitiveKind> 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<ProfileMetricPrimitiveKind> 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<String>,
|
|
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<String>,
|
|
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<String>,
|
|
#[serde(default)]
|
|
pub value_param: Option<String>,
|
|
#[serde(default)]
|
|
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
|
#[serde(default)]
|
|
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
|
#[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")]
|
|
legacy_params: Vec<LegacyMetricParamRecord>,
|
|
}
|
|
|
|
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) {
|
|
if 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<Panel>,
|
|
}
|
|
|
|
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<ParamDeclarationRecord>,
|
|
#[serde(default)]
|
|
pub metrics: Vec<MetricDeclarationRecord>,
|
|
#[serde(default)]
|
|
pub dashboards: Vec<Dashboard>,
|
|
}
|
|
|
|
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<io::Error> for ProfileError {
|
|
fn from(err: io::Error) -> Self {
|
|
ProfileError::Io(err)
|
|
}
|
|
}
|
|
|
|
impl Profile {
|
|
pub(crate) fn load_legacy_store(path: &Path) -> Option<Vec<Profile>> {
|
|
let contents = fs::read_to_string(path).ok()?;
|
|
match serde_json::from_str::<Vec<Profile>>(&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<String>,
|
|
) -> Result<(), ProfileError> {
|
|
if let Some(name) = &name {
|
|
if 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<Dashboard, ProfileError> {
|
|
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<Dashboard, ProfileError> {
|
|
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::engine::data_value::MetricPrimitiveKind;
|
|
use crate::api::dto::LogsParams;
|
|
use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine};
|
|
use crate::engine::timestamp::Timestamp;
|
|
|
|
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::{self, EngineMessage, EngineQuery, EngineResponse, IngestedLine};
|
|
use crate::engine::timestamp::Timestamp;
|
|
|
|
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<level>\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<level>\w+)", "level");
|
|
settings.record_param_declaration(&settings_path, "web", ¶m_req).unwrap();
|
|
|
|
let metric_req = metric_request("requests", r"duration=(?P<duration>\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<level>\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<level>\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<duration>\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<level>\w+)", "level"),
|
|
)
|
|
.unwrap();
|
|
settings
|
|
.record_param_declaration(
|
|
&settings_path,
|
|
"web",
|
|
¶m_request("duration", r"request duration=(?P<duration>\d+)", "duration"),
|
|
)
|
|
.unwrap();
|
|
settings
|
|
.record_metric_declaration(
|
|
&settings_path,
|
|
"web",
|
|
&metric_request("requests", r"request duration=(?P<duration>\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<level>\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<duration>\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<path>\\S+) duration=(?P<duration>\\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<user>\\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<user>\\S+) logged in", "capture_group": "user", "kind": "string" },
|
|
{ "name": "gap", "message_match_regex": "user=(?P<user>\\S+) logged in", "capture_group": "user", "kind": "duration" }
|
|
],
|
|
"metrics": [
|
|
{
|
|
"name": "login_gap",
|
|
"message_match_regex": "user=(?P<user>\\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<path>\\S+)", "capture_group": "path", "kind": "string" }
|
|
],
|
|
"metrics": [
|
|
{
|
|
"name": "requests",
|
|
"message_match_regex": "request path=(?P<path>\\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<path>\S+)", "path"),
|
|
)
|
|
.unwrap();
|
|
settings
|
|
.record_metric_declaration(&settings_path, "web", &metric_request("requests", r"path=(?P<path>\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<dx>\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");
|
|
}
|
|
}
|