Add DELETE /api/metrics/{name} to allow deleting a declared metric (ticket 051)

Wires the engine's existing RemoveMetricDeclaration message (previously
only used internally by delete_profile's cleanup) up to a new DELETE
route, persists the removal into the active profile so it survives a
restart, and adds a delete affordance in the Metrics UI.

Decisions:
- Bucket-value history under the deleted metric's name is purged, not
  left orphaned (engine.rs's new remove_metric_declaration reuses
  update_metric's clear_metric_data helper). This is what lets a
  dashboard panel or PromQL query still referencing the metric by name
  fail with a readable "unknown metric" error instead of silently
  returning stale data.
- evaluate_promql now errors when a metric has neither a declaration nor
  any bucket data, since a metric ingested via --metrics-url scraping
  never gets a MetricDeclaration but must stay queryable. Verified with
  a seeded fixture (promql_query_against_deleted_metric_fails_with_readable_error
  in src/api.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-06 09:41:07 +01:00
co-authored by Claude Sonnet 5
parent d17cfe9b83
commit 466c81fa94
6 changed files with 312 additions and 72 deletions
+151 -1
View File
@@ -115,6 +115,21 @@ impl ApiState {
);
}
}
/// Best-effort, matching `persist_param_declaration`'s intent — a no-op if no profile is
/// active, and I/O failures are only logged, since the engine's removal is already the source
/// of truth for what the live process shows.
fn persist_metric_declaration_removal(&self, name: &str) {
let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else {
return;
};
if let Err(err) = settings.remove_metric_declaration(&self.settings_path, &profile_name, name) {
eprintln!(
"warning: failed to remove metric declaration '{name}' from profile '{profile_name}': {err}"
);
}
}
}
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
@@ -132,7 +147,7 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat
.route("/api/metrics", get(list_metrics).post(declare_metric))
.route(
"/api/metrics/{name}",
get(get_metric_bucket_values).put(update_metric),
get(get_metric_bucket_values).put(update_metric).delete(delete_metric),
)
.route("/api/query", get(run_promql_query))
.route("/api/settings", get(get_settings).patch(update_settings))
@@ -395,6 +410,35 @@ async fn update_metric(
}
}
/// Deletes the metric declaration named by the URL path — both from the live engine index and,
/// if a profile is active, from persisted settings (ticket 051), so it doesn't reappear on
/// restart. Its recorded bucket-value history is purged along with it (see the comment on
/// `EngineMessage::RemoveMetricDeclaration`'s handler in `engine.rs`), rather than left orphaned,
/// so a dashboard panel or PromQL query still referencing the metric by name fails with a clear
/// "unknown metric" error instead of silently keeping stale data alive.
async fn delete_metric(
State(state): State<ApiState>,
AxumPath(name): AxumPath<String>,
) -> impl IntoResponse {
let (respond_to, response) = oneshot::channel();
if state
.engine
.send(EngineMessage::RemoveMetricDeclaration { name: name.clone(), respond_to })
.await
.is_err()
{
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
match response.await {
Ok(true) => {
state.persist_metric_declaration_removal(&name);
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
}
/// Malformed pairs (no `=`) are skipped rather than rejected.
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
raw.split(',')
@@ -1106,6 +1150,112 @@ mod tests {
assert!(body["error"].as_str().unwrap().contains("duration"));
}
#[tokio::test]
async fn deletes_metric_and_it_disappears_from_the_list() {
let (app, _engine, _settings, _dir) = test_app();
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"label_params": []
}),
)
.await;
let status = delete_request(&app, "/api/metrics/requests").await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = get_json(&app, "/api/metrics").await;
assert_eq!(status, StatusCode::OK);
assert!(body.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn deleting_unknown_metric_name_returns_404_not_500() {
let (app, _engine, _settings, _dir) = test_app();
let status = delete_request(&app, "/api/metrics/does-not-exist").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
/// Acceptance criterion: deleting a metric persists the removal into the active profile too, so
/// it doesn't reappear from `settings.json` on the next restart.
#[tokio::test]
async fn deleting_metric_persists_removal_and_it_does_not_reappear_after_restart() {
let (app, _engine, settings_handle, dir) = test_app();
let settings_path = dir.path().join("settings.json");
{
let mut settings = settings_handle.lock().unwrap();
settings.create_profile(&settings_path, "web".to_string()).unwrap();
settings.set_active_profile(&settings_path, Some("web".to_string())).unwrap();
}
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"label_params": []
}),
)
.await;
assert_eq!(Settings::load(&settings_path).profile("web").unwrap().metrics.len(), 1);
let status = delete_request(&app, "/api/metrics/requests").await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert!(Settings::load(&settings_path).profile("web").unwrap().metrics.is_empty());
}
/// Acceptance criterion: a dashboard panel (PromQL query) that still references a metric by
/// name after it's been deleted fails with a readable "unknown metric" error, rather than a
/// panic or a silently-empty series — seeded here by declaring, ingesting into, deleting, then
/// re-querying the same metric name.
#[tokio::test]
async fn promql_query_against_deleted_metric_fails_with_readable_error() {
let (app, engine, _settings, _dir) = test_app();
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
send_json(
&app,
"POST",
"/api/metrics",
json!({
"name": "requests",
"message_match_regex": r"request path=(?P<path>\S+)",
"label_params": ["path"]
}),
)
.await;
engine
.send(EngineMessage::Ingest(IngestedLine {
sequence: 0,
timestamp: Timestamp::from_millis(0),
text: "request path=/a".to_string(),
}))
.await
.unwrap();
// Sanity check: the dashboard-panel-style query works before deletion.
let (status, _body) = get_json(&app, "/api/query?promql=requests").await;
assert_eq!(status, StatusCode::OK);
let status = delete_request(&app, "/api/metrics/requests").await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = get_json(&app, "/api/query?promql=requests").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"].as_str().unwrap().contains("unknown metric"),
"expected a readable 'unknown metric' error, got: {body}"
);
}
#[tokio::test]
async fn queries_bucket_values_with_label_filter_and_range() {
let (app, engine, _settings, _dir) = test_app();
+85 -1
View File
@@ -204,7 +204,7 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
let _ = respond_to.send(result);
}
EngineMessage::RemoveMetricDeclaration { name, respond_to } => {
let existed = index.remove(&IndexKey::MetricDeclaration { name }).is_some();
let existed = remove_metric_declaration(&mut index, &name);
let _ = respond_to.send(existed);
}
EngineMessage::Query { query, respond_to } => {
@@ -442,6 +442,18 @@ fn clear_metric_data(index: &mut BTreeMap<IndexKey, DataValue>, metric_name: &st
});
}
/// Removes `name`'s declaration and, per ticket 051, purges its bucket history along with it
/// (reusing `clear_metric_data`, same as `update_metric`'s re-backfill does for the old
/// declaration) rather than leaving it orphaned — an orphaned bucket would let a stale dashboard
/// panel or PromQL query keep silently returning old data by name instead of failing with a clear
/// "unknown metric" error (see the declared-or-has-data check in `evaluate_promql`). Returns
/// whether a declaration existed, which the caller maps to 404 vs success at the API layer.
fn remove_metric_declaration(index: &mut BTreeMap<IndexKey, DataValue>, name: &str) -> bool {
let existed = index.remove(&IndexKey::MetricDeclaration { name: name.to_string() }).is_some();
clear_metric_data(index, name);
existed
}
/// Returns `None` on a parse failure, which the caller treats as "this match doesn't apply".
fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option<MetricPrimitive> {
match kind {
@@ -1565,6 +1577,20 @@ fn evaluate_promql(
start_ms: Option<i64>,
end_ms: Option<i64>,
) -> Result<Vec<PromqlSeriesDto>, String> {
// Ticket 051: a metric with neither a declaration nor any recorded bucket data is genuinely
// unknown (as opposed to a declared-but-empty-so-far metric, or an undeclared scraped
// Prometheus metric ingested via `--metrics-url`'s `IngestMetricSample`, which never gets a
// `MetricDeclaration` but still has bucket data) — most commonly because a dashboard panel
// still references a metric that has since been deleted. Failing here with a clear error beats
// a panic or a silently-empty result.
if !index.contains_key(&IndexKey::MetricDeclaration { name: parsed.metric_name.clone() })
&& bucket_values(index, &parsed.metric_name, None).next().is_none()
{
return Err(format!(
"unknown metric \"{}\": no declaration or recorded data for it (was it deleted?)",
parsed.metric_name
));
}
if matches!(parsed.agg, Some(AggOp::Sum) | Some(AggOp::Avg))
&& !parsed.inner_rate
&& declared_value_kind(index, &parsed.metric_name) == Some(MetricPrimitiveKind::String)
@@ -2098,6 +2124,64 @@ mod tests {
assert!(metric_declarations_range(&index).next().is_none());
}
#[test]
fn remove_metric_declaration_reports_whether_it_existed() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
assert!(remove_metric_declaration(&mut index, "requests"));
// Removing an already-gone (or never-declared) metric is a clean `false`, not a panic —
// this is what the API layer's 404 branch relies on.
assert!(!remove_metric_declaration(&mut index, "requests"));
assert!(!remove_metric_declaration(&mut index, "never-declared"));
}
/// Ticket 051: deleting a metric purges its bucket history too, so a stale PromQL query
/// (e.g. a dashboard panel that still names the deleted metric) fails with a readable
/// "unknown metric" error instead of silently returning orphaned data.
#[test]
fn remove_metric_declaration_purges_bucket_history_so_promql_reports_unknown_metric() {
let mut index = BTreeMap::new();
declare_requests_metric(&mut index);
ingest_log(&mut index, 0, "request path=/a");
let parsed = promql::parse_query("requests").unwrap();
assert!(evaluate_promql(&index, &parsed, None, None).unwrap().len() == 1);
assert!(remove_metric_declaration(&mut index, "requests"));
assert!(bucket_values(&index, "requests", None).next().is_none());
let err = evaluate_promql(&index, &parsed, None, None).unwrap_err();
assert!(err.contains("unknown metric"), "unexpected error message: {err}");
}
/// A metric that was ingested via `--metrics-url` scraping (`IngestMetricSample`) never gets a
/// `MetricDeclaration` at all, so the "unknown metric" check must key off having *either* a
/// declaration *or* recorded data — not declaration alone — or every scraped metric would be
/// rejected as unknown.
#[test]
fn undeclared_metric_with_bucket_data_is_still_queryable_via_promql() {
let mut index = BTreeMap::new();
push_bucket_value(
&mut index,
"scraped_metric",
BTreeMap::new(),
0,
Timestamp::from_millis(0),
MetricPrimitive::Number(Number::Float(1.0)),
);
let parsed = promql::parse_query("scraped_metric").unwrap();
let series = evaluate_promql(&index, &parsed, None, None).unwrap();
assert_eq!(series.len(), 1);
}
#[test]
fn promql_rejects_metric_with_no_declaration_and_no_data() {
let index = BTreeMap::new();
let parsed = promql::parse_query("never_declared").unwrap();
let err = evaluate_promql(&index, &parsed, None, None).unwrap_err();
assert!(err.contains("unknown metric"), "unexpected error message: {err}");
}
#[test]
fn update_metric_rejects_when_no_declaration_exists_yet() {
let mut index = BTreeMap::new();
+43
View File
@@ -694,6 +694,27 @@ impl Settings {
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)?;
@@ -1036,6 +1057,28 @@ mod tests {
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() {