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
+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();