Files
ottyl/src/profile.rs
T

1559 lines
69 KiB
Rust

//! Named, persistable bundles of parsing/indexing rules and dashboards for a CLI app.
//! Ticket 049 folded profile storage inline into `Settings` (`settings.json`) instead of a separate
//! `profiles.json`; this module still owns the `Profile`/`Dashboard`/declaration data model, plus the
//! CRUD methods on `Settings` (`impl Settings` below `ProfileError`) that used to live on the
//! now-removed `ProfileStore`.
#![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::data_value::MetricPrimitiveKind;
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
use crate::settings::Settings;
use tokio::sync::oneshot;
/// Name of the profile that always exists and can't be deleted (see `Settings::ensure_default_profile`).
pub const DEFAULT_PROFILE_NAME: &str = "Default";
/// Mirrors `data_value::MetricPrimitiveKind` with `Serialize`/`Deserialize` so profiles can round-trip through JSON, since the engine-internal type only derives `Serialize`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileMetricPrimitiveKind {
String,
Number,
Timestamp,
/// Mirrors `MetricPrimitiveKind::Duration`.
Duration,
/// Mirrors `MetricPrimitiveKind::Boolean`.
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,
}
}
}
/// A stored param declaration, matching `DeclareParamRequest`'s shape (== `data_value::ParamDeclaration`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParamDeclarationRecord {
pub name: String,
pub message_match_regex: String,
pub capture_group: String,
/// See `crate::data_value::ParamDeclaration::json_field_path`; `#[serde(default)]`
/// so profiles saved before this field existed still load as plain regex-capture-group params.
#[serde(default)]
pub json_field_path: Option<String>,
pub kind: ProfileMetricPrimitiveKind,
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`; `#[serde(default)]` so
/// profiles saved before ticket 038 (where a `Duration`-kind value param implicitly meant this)
/// still load — `migrate_duration_value_params` below converts those.
#[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,
// A profile only ever stores manually-declared params (see `record_param_declaration`).
auto_derived: false,
log_stream_gated: false,
}
}
}
/// Pre-037 role of an inline metric param matcher — a label identifying a bucket, or the (at most
/// one) value that becomes the event's captured value. Kept only so `LegacyMetricParamRecord` can
/// still deserialize a profile saved before params were unified into standalone `ParamDeclaration`s
/// referenced by name; see `migrate_legacy_metric_params`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
enum LegacyParamRole {
#[default]
Label,
Value,
}
/// Pre-037 inline metric param matcher shape (formerly `data_value::RegexToMetricPrimitiveMatcher`),
/// kept only to support migrating profiles saved before ticket 037 unified params — see
/// `migrate_legacy_metric_params`. Never produced when serializing a record built from a
/// `DeclareMetricRequest`.
#[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,
}
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape. `legacy_params` is only
/// ever populated by deserializing a pre-037 profile file (old key name `"params"`) — see
/// `migrate_legacy_metric_params`, which converts it into `label_params`/`value_param` plus any
/// implicitly-created standalone params, and empties it back out so a subsequent save never writes
/// the old shape again.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetricDeclarationRecord {
pub name: String,
pub message_match_regex: String,
/// Names of standalone `ParamDeclarationRecord`s used as this metric's bucket labels.
#[serde(default)]
pub label_params: Vec<String>,
/// Name of the standalone `ParamDeclarationRecord` (if any) whose value becomes this metric's bucket value.
#[serde(default)]
pub value_param: Option<String>,
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
#[serde(default)]
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
#[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(),
}
}
}
/// Migrates every metric in `profile` still carrying pre-037 inline matchers
/// (`MetricDeclarationRecord::legacy_params`, deserialized from an old profile file's `"params"`
/// key) into the unified shape: each matcher becomes a reference to a standalone
/// `ParamDeclarationRecord`, resolved by reusing an existing declared param of the exact same name
/// and shape (regex/capture-group-or-json-field-path/kind) when one exists, or else creating a new
/// one implicitly named `<metric_name>.<param_name>` (added to `profile.params`). Deterministic
/// naming means re-running this on an already-migrated profile is a no-op, and running it twice on
/// the same legacy data produces the same result. Returns whether anything changed, so the caller
/// can decide whether to persist the migration.
fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
let mut migrated = false;
for metric_index in 0..profile.metrics.len() {
if profile.metrics[metric_index].legacy_params.is_empty() {
continue;
}
let metric_name = profile.metrics[metric_index].name.clone();
let message_match_regex = profile.metrics[metric_index].message_match_regex.clone();
let legacy = std::mem::take(&mut profile.metrics[metric_index].legacy_params);
let mut label_params = Vec::new();
let mut value_param = None;
for matcher in &legacy {
let param_name =
resolve_or_create_legacy_param(profile, &metric_name, &message_match_regex, matcher);
match matcher.role {
LegacyParamRole::Label => label_params.push(param_name),
LegacyParamRole::Value => value_param = Some(param_name),
}
}
profile.metrics[metric_index].label_params = label_params;
profile.metrics[metric_index].value_param = value_param;
migrated = true;
}
migrated
}
/// Resolves one legacy matcher to a standalone param name, reusing an existing declared param of
/// the same bare name and shape, or else creating `<metric_name>.<param_name>` on `profile.params`.
fn resolve_or_create_legacy_param(
profile: &mut Profile,
metric_name: &str,
message_match_regex: &str,
matcher: &LegacyMetricParamRecord,
) -> String {
if let Some(existing) = profile.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 !profile.params.iter().any(|p| p.name == implicit_name) {
profile.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,
// Pre-037, a Value-role Duration matcher implicitly meant "elapsed time since the
// previous same-labeled event" (ticket 038's now-explicit `elapsed_since_last`) — carry
// that meaning forward rather than dropping it into a meaningless literal-parse matcher.
elapsed_since_last: matcher.role == LegacyParamRole::Value
&& matcher.kind == ProfileMetricPrimitiveKind::Duration,
});
}
implicit_name
}
/// Migrates every already-declared `Duration`-kind param that's referenced as some metric's
/// `value_param` into the explicit `elapsed_since_last` extractor (ticket 038). Before ticket 038, a
/// `Duration`-kind value param implicitly meant "elapsed time since the previous same-labeled
/// event" (see `crate::engine::apply_metric_to_log`'s pre-038 history) — this migration makes that
/// meaning explicit so `Duration` kind no longer has a role-dependent special case. Only params
/// actually referenced as *some* metric's value param are converted: a `Duration`-kind param used
/// purely as a label param already meant a literal parsed millisecond count, and keeps meaning that.
/// Runs after `migrate_legacy_metric_params` (which populates `value_param` from pre-037
/// `legacy_params` and already sets `elapsed_since_last` correctly for those via
/// `resolve_or_create_legacy_param`), so this only has new work to do for profiles saved between
/// ticket 037 and ticket 038 landing, where `value_param` already pointed at a bare `Duration`-kind
/// param straight from the unified shape. Returns whether anything changed.
fn migrate_duration_value_params(profile: &mut Profile) -> bool {
let value_param_names: std::collections::HashSet<&str> =
profile.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect();
let mut migrated = false;
for param in &mut profile.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
}
/// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChartType {
Bar,
Line,
Pie,
}
/// One panel within a dashboard: a PromQL query plus how to chart its result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Panel {
pub id: String,
pub title: String,
pub promql: String,
pub chart_type: ChartType,
}
/// A named set of panels, saved as part of a profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Dashboard {
pub id: String,
pub name: String,
#[serde(default)]
pub panels: Vec<Panel>,
}
/// Generates an id by hashing a seed (dashboard/panel name) with the current time, avoiding a `uuid` dependency for what's otherwise an opaque string.
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() }
}
}
/// Name of the metric seeded into a brand-new `Default` profile, used to drive its starter dashboard's two panels.
const SEEDED_METRIC_NAME: &str = "log_category";
const SEEDED_METRIC_LABEL: &str = "category";
/// A counter that splits every line into `category="error"`/`"other"` via `matched_labels`/`unmatched_labels` rather than a captured param, so case variants like "ERROR" collapse into one series; the `regex` crate lacks the lookaround needed to express "didn't match" any other way.
fn seeded_error_split_metric() -> 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(),
}
}
/// The starter dashboard seeded into a brand-new `Default` profile: a line chart and a pie chart, both querying the seeded error-split counter.
fn seeded_default_dashboard() -> 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
}
/// A named bundle of parsing/indexing rules for a particular CLI app, plus any saved dashboards.
#[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(),
}
}
}
/// Replays every param/metric declaration stored in `profile` into `engine` (used on startup and in tests); any rejected declaration or send/recv failure is logged to stderr and skipped rather than panicking.
pub async fn load_into_engine(engine: &EngineHandle, profile: &Profile) {
for record in &profile.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 '{}'", profile.name);
return;
}
match response.await {
Ok(Ok(())) => {}
Ok(Err(err)) => eprintln!(
"warning: profile '{}' param declaration '{name}' rejected by engine: {err}",
profile.name
),
Err(_) => eprintln!(
"warning: engine dropped response while declaring param '{name}' from profile '{}'",
profile.name
),
}
}
for record in &profile.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 '{}'", profile.name);
return;
}
match response.await {
Ok(Ok(())) => {}
Ok(Err(err)) => eprintln!(
"warning: profile '{}' metric declaration '{name}' rejected by engine: {err}",
profile.name
),
Err(_) => eprintln!(
"warning: engine dropped response while declaring metric '{name}' from profile '{}'",
profile.name
),
}
}
}
/// Error returned by `Settings`'s profile-related mutations and lookups.
#[derive(Debug)]
pub enum ProfileError {
/// No profile with the given name exists.
NotFound,
/// A profile with the given name already exists.
DuplicateName,
/// The `Default` profile can't be deleted — there must always be at least one profile to fall back to.
CannotDeleteDefault,
/// Underlying I/O failure reading/writing the profile store or settings file.
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)
}
}
/// Reads a legacy pre-049 `profiles.json` file (back when profiles lived in a separate file rather
/// than inline in `settings.json`) for `Settings::migrate_legacy_profiles_file`. Returns `None` if
/// the file doesn't exist or can't be read/parsed — treated as "nothing to migrate" rather than
/// failing settings load.
pub(crate) fn load_legacy_profile_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
}
}
}
/// Turns any pre-unification/pre-explicit-duration metric or param shapes still present in
/// `profiles` into their current form; used by `Settings::load` on every startup (previously done
/// by the now-removed `ProfileStore::load`, before ticket 049 folded profile storage into
/// `settings.json`). Returns whether anything changed, so the caller can decide whether to
/// re-persist.
///
/// Ticket 037: turn any pre-unification inline metric matchers into standalone param references.
/// Deterministic naming makes this idempotent, so it's safe to run on every load; persisting the
/// result isn't required for correctness (migration re-runs identically next time), but it's done
/// here anyway so the on-disk file only carries the legacy `"params"` shape once, on the very next
/// save.
///
/// Ticket 038: follows on immediately after — once `migrate_legacy_metric_params` has resolved
/// every metric's `value_param`, convert any `Duration`-kind param still referenced that way into
/// the explicit `elapsed_since_last` extractor, so a profile saved either before 037 (inline
/// matchers) or between 037 and 038 (bare `value_param` reference) ends up behaving identically
/// under the new, explicit shape.
pub(crate) fn migrate_legacy_profile_shapes(profiles: &mut [Profile]) -> bool {
let mut any_migrated = false;
for profile in profiles {
let legacy_migrated = migrate_legacy_metric_params(profile);
let duration_migrated = migrate_duration_value_params(profile);
if legacy_migrated || duration_migrated {
any_migrated = true;
}
}
any_migrated
}
/// Profile CRUD, previously on the standalone `ProfileStore` (removed by ticket 049): every mutation
/// here reads/writes `self.profiles` directly and persists via `Settings::save`, so `settings.json` is
/// the single source of truth instead of a separate file kept in sync alongside it.
impl Settings {
pub fn profile(&self, name: &str) -> Option<&Profile> {
self.profiles.iter().find(|p| p.name == name)
}
/// Creates a new profile named `name`, persisting settings, copying the active profile's
/// params/metrics/dashboards as a starting point rather than starting blank. Fails with
/// `DuplicateName` if the name exists.
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(())
}
/// Deletes the profile named `name`, persisting settings, clearing `active_profile` if it was
/// active. Fails with `NotFound`, or `CannotDeleteDefault` for the `Default` profile.
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(())
}
/// Ensures a `Default` profile exists (seeding a starter dashboard/metric only on first
/// creation), and falls back to active if nothing else is. Called once on startup before anything
/// else touches profiles.
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(seeded_error_split_metric());
profile.dashboards.push(seeded_default_dashboard());
self.profiles.push(profile);
}
if self.active_profile.is_none() {
self.active_profile = Some(DEFAULT_PROFILE_NAME.to_string());
}
self.save(settings_path)?;
Ok(())
}
/// Sets (or clears, with `None`) the active profile. Fails with `NotFound` if `name` is `Some`
/// and no such profile exists. Doesn't itself reload declarations into a running engine — that
/// happens on next startup via `load_into_engine`.
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(())
}
/// Empties `profile_name`'s stored param declarations, persisting settings — the counterpart to
/// `record_param_declaration` for `DELETE /api/params` (ticket 052). A profile only ever stores
/// manually-declared params (auto-derived ones live only in the engine, never persisted here), so
/// this is enough to keep the whole set from reappearing on next load; the engine-side clear
/// re-derives whatever auto params the retained log text still supports.
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(())
}
/// Records/updates a declared param into `profile_name`'s stored declarations (matched by param
/// name), persisting settings. Intended to be called by the HTTP API alongside declaring against
/// the engine, so declarations survive a restart.
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(())
}
/// Records/updates a declared metric into `profile_name`'s stored declarations (matched by metric
/// name), persisting settings; same integration intent as `record_param_declaration`.
///
/// `promoted_params` is any of the metric's referenced `label_params`/`value_param` that live
/// only as auto-derived engine state (see `crate::engine::auto_declare_params`) rather than a
/// persisted profile param — the caller (`ApiState::persist_metric_declaration`) resolves these
/// against the live engine before calling in. Without persisting them alongside the metric, a
/// restart would replay the metric before any log line re-derives its auto params, and the
/// engine would reject it as referencing an undeclared param. Each is added only if `profile
/// .params` doesn't already have an entry of that name, so an existing (e.g. manually-declared)
/// param is never clobbered.
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(())
}
/// Same as `record_metric_declaration`, for an in-place edit that may rename the metric: drops
/// any persisted record under `old_name` first (when it differs from the new name) so a rename
/// doesn't leave a stale duplicate declaration behind, then upserts by the new name as usual.
/// `promoted_params` has the same meaning as in `record_metric_declaration`.
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(())
}
/// Removes `name`'s persisted metric declaration from `profile_name` (ticket 051), so it doesn't
/// reappear from `settings.json` on the next restart. A no-op (not an error) if the profile has
/// no record under that name — matches `Settings::remove_css`'s sibling calls in spirit, but
/// deleting an already-gone declaration is fine here since the caller (the API layer) has
/// already confirmed against the live engine index whether the metric existed at all.
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(())
}
/// Lists the dashboards saved on `profile_name`; fails with `NotFound` if it doesn't exist.
pub fn list_dashboards(&self, profile_name: &str) -> Result<&[Dashboard], ProfileError> {
let profile = self.profile(profile_name).ok_or(ProfileError::NotFound)?;
Ok(&profile.dashboards)
}
/// Creates a new, empty dashboard named `name` on `profile_name`, persisting settings; fails
/// with `NotFound` if the profile doesn't exist.
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)
}
/// Replaces the dashboard identified by `dashboard_id` with `dashboard`, persisting settings;
/// `dashboard.id` is forced to `dashboard_id` so a caller can't change a dashboard's identity
/// through this call.
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)
}
/// Deletes the dashboard identified by `dashboard_id` from `profile_name`, persisting settings;
/// fails with `NotFound` if either doesn't exist.
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::data_value::MetricPrimitiveKind;
use crate::dto::LogsParams;
use crate::engine::{EngineMessage, EngineQuery, EngineResponse, IngestedLine};
use crate::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,
}
}
/// Ticket 075: reads no longer go through `EngineHandle::tx` — this reads straight off
/// `EngineHandle::reader_factory`, same as the real API layer. Only used here for
/// `ParamDeclarations`/`MetricDeclarations` lookups, neither of which ever needs the
/// `EnsureSortHelperIndex` round-trip a sorted `Logs` query would (see `api.rs`'s own `ask`).
async fn ask(engine: &EngineHandle, query: EngineQuery) -> EngineResponse {
let read_handle = engine.reader_factory.handle();
let guard = read_handle.enter().expect("engine unavailable");
crate::engine::handle_query_ro(&guard, 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)));
// Reload from disk: profiles persisted.
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()));
// Idempotent: doesn't duplicate the profile or clobber an already-active (non-Default) profile.
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));
// Reload (simulating a restart) and ensure_default again: must not re-seed or duplicate.
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::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);
load_into_engine(&engine_tx, &default_profile).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", &param_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);
// With no active profile, new profiles start empty as before.
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()));
// Switching to another known profile works and persists.
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()));
// Setting an unknown profile active is rejected, leaving the previous value.
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()));
// Clearing the active profile works.
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", &param_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");
// Re-declaring the same name updates in place rather than duplicating.
let param_req_v2 = param_request("level", r"lvl=(?P<level>\w+)", "level");
settings.record_param_declaration(&settings_path, "web", &param_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+)");
// Reload from disk: recorded declarations survive a restart.
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());
// Removing an unknown metric name is a harmless no-op, not an error.
settings.remove_metric_declaration(&settings_path, "web", "never-declared").unwrap();
// Reload from disk: the removal itself survives a restart (ticket 051's acceptance criterion).
let reloaded = Settings::load(&settings_path);
assert!(reloaded.profile("web").unwrap().metrics.is_empty());
}
/// Acceptance criterion: declaring params/metrics, "restarting" (reloading settings from disk), and replaying into a fresh engine reproduces the same declarations and still ingests lines, without re-declaring by hand.
#[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();
// Declare directly into a profile, as the HTTP API will do via the `record_*_declaration` hooks.
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings
.record_param_declaration(
&settings_path,
"web",
&param_request("level", r"level=(?P<level>\w+)", "level"),
)
.unwrap();
// A metric now references params by name (ticket 037), so "duration" must be declared as a
// standalone param before the metric below can reference it as a label param.
settings
.record_param_declaration(
&settings_path,
"web",
&param_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();
// Simulate a restart: drop everything in memory, reload from disk.
drop(settings);
let settings = Settings::load(&settings_path);
let active_name = settings.active_profile.clone().unwrap();
let profile = settings.profile(&active_name).unwrap().clone();
// Spawn a fresh engine and replay the profile's declarations into it.
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(settings.clone()));
let engine_tx = crate::engine::spawn(settings_handle, 16);
load_into_engine(&engine_tx, &profile).await;
// Ingest a line and confirm the engine works with the replayed declarations (no re-declaration needed).
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");
}
/// Acceptance criterion: a dashboard (with a panel) survives reloading settings from disk.
#[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);
// Simulate a restart: drop and reload from disk.
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"}"#);
// Deleting removes it, and that too persists.
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());
// Unknown profile/dashboard both fail with NotFound.
assert!(matches!(
reloaded_again.list_dashboards("ghost"),
Err(ProfileError::NotFound)
));
}
/// Writes a pre-037 `settings.json` (with an inline `profiles` array whose metrics carry legacy
/// `"params"` matchers rather than `label_params`/`value_param`) directly to disk, bypassing
/// `Settings`/`Profile` entirely so this doesn't accidentally exercise the current
/// (post-migration) shape. `profiles_json` is the raw JSON array of profiles.
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();
}
/// Acceptance criterion: a profile saved before ticket 037 (inline `param_name`/`capture_group`/
/// `kind`/`role` matchers embedded in the metric) loads with those matchers turned into
/// standalone param declarations referenced by name, and the metric still ingests identically.
#[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();
// No pre-existing bare "path"/"duration" param, so implicit ones were created, named after the metric.
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()));
// The migration is persisted, so the on-disk file no longer carries the legacy "params" shape.
let reloaded_raw = fs::read_to_string(&settings_path).unwrap();
assert!(!reloaded_raw.contains("param_name"));
// Replaying into a fresh engine behaves identically to before migration: the metric still ingests and buckets.
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);
load_into_engine(&engine_tx, &profile).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()]);
});
}
/// Acceptance criterion (ticket 038): a pre-037 legacy profile whose inline `Value`-role matcher
/// used `kind: "duration"` (the old "elapsed time since previous event" special case) migrates
/// into a standalone param with the new explicit `elapsed_since_last: true` extractor, not just a
/// bare `Duration`-kind param that would now (post-038) mean a literal parsed millisecond count.
#[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()));
}
/// Acceptance criterion (ticket 038): a profile saved after ticket 037 landed but before ticket
/// 038 — so it already carries the unified `label_params`/`value_param` shape rather than legacy
/// inline matchers, with a bare `Duration`-kind param referenced as a metric's `value_param` — is
/// migrated the same way: the param gains `elapsed_since_last: true` so its behavior is unchanged
/// (elapsed time since the previous same-labeled event) under the new explicit extractor.
#[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);
// A Duration-kind param not referenced as any value_param is left untouched (still a literal parse).
assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last);
}
/// When a bare-named param already declared on the profile matches a legacy matcher's shape
/// exactly (same regex/capture-group/kind), migration reuses it by name instead of creating a
/// redundant `<metric>.<param>` declaration.
#[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();
// Reused the existing "path" declaration rather than creating "requests.path".
assert_eq!(profile.params.len(), 1);
assert_eq!(profile.params[0].name, "path");
assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]);
}
/// Migration is idempotent: loading an already-migrated (or never-legacy) profile a second time
/// doesn't change anything or re-trigger a save.
#[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",
&param_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);
}
/// Acceptance criterion: a metric declared against a param that only exists as auto-derived
/// engine state (not yet a persisted profile param) still ingests correctly after a simulated
/// restart, because `record_metric_declaration`'s `promoted_params` persists that param into the
/// profile alongside the metric. Without this, `load_into_engine` would reject the metric on
/// restart since declarations replay before any log line re-derives the auto param.
#[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();
// Simulate the auto-derive path: ingest a line carrying a logfmt key before any param is
// manually declared, so `dx` only exists as engine-internal auto-derived state.
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();
// Declare a metric referencing the auto-derived "dx" param — this succeeds live (the param
// exists in the engine), mirroring the interactive-declare flow that hit this bug.
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();
// Promote "dx" from its live (auto-derived) engine state, as `ApiState::persist_metric_declaration` does.
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();
// Simulate a restart: drop everything, reload from disk into a *fresh* engine with no prior ingestion.
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);
load_into_engine(&fresh_engine_tx, &profile).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");
}
}