Allow editing an existing metric declaration in place (ticket 022)

Clicking a declared metric badge in Metrics.tsx now loads its regex/params
into the declaration form for editing (in addition to selecting it for the
bucket-values view), and shows those properties inline once selected rather
than only on hover. The backend gains PUT /api/metrics/{name}, backed by a
new engine::update_metric that reuses declare_metric's validation, requires
the metric to already exist, supports renaming, and re-backfills bucket data
from the new regex/params instead of leaving stale buckets from the old one.
Submitting the form while editing calls the update endpoint in place rather
than creating a second declaration; declaring a brand-new metric is unchanged.

Note: tickets/022-edit-existing-metric-declarations.md does not exist on this
worktree's branch (it was added to tickets/ on main after this branch was
created), so there is nothing to delete here.

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-05 19:38:00 +01:00
co-authored by Claude Sonnet 5
parent e6e5bc8f50
commit 56da086c7a
5 changed files with 467 additions and 21 deletions
+192 -5
View File
@@ -91,6 +91,12 @@ pub enum EngineMessage {
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
/// Updates an existing metric declaration named `name` in place — same validation as `DeclareMetric`, but fails if no declaration named `name` exists (rather than silently creating one), and re-backfills bucket data from scratch so edited params/regex don't leave stale buckets from the old declaration mixed in.
UpdateMetric {
name: String,
request: DeclareMetricRequest,
respond_to: oneshot::Sender<Result<(), String>>,
},
RemoveMetricDeclaration {
name: String,
respond_to: oneshot::Sender<bool>,
@@ -188,6 +194,10 @@ pub async fn run(mut rx: mpsc::Receiver<EngineMessage>, settings: SettingsHandle
let result = declare_metric(&mut index, request);
let _ = respond_to.send(result);
}
EngineMessage::UpdateMetric { name, request, respond_to } => {
let result = update_metric(&mut index, &name, request);
let _ = respond_to.send(result);
}
EngineMessage::RemoveMetricDeclaration { name, respond_to } => {
let existed = index.remove(&IndexKey::MetricDeclaration { name }).is_some();
let _ = respond_to.send(existed);
@@ -225,11 +235,8 @@ fn declare_param(
Ok(())
}
/// Validates every param matcher's capture-group reference (not just the first bad one) so a rejection reports every problem at once.
fn declare_metric(
index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareMetricRequest,
) -> Result<(), String> {
/// Validates every param matcher's capture-group reference (not just the first bad one) so a rejection reports every problem at once, and the at-most-one-Value-param rule. Shared by `declare_metric` and `update_metric` so both reject a bad declaration the same way.
fn validate_metric_request(request: &DeclareMetricRequest) -> Result<Regex, String> {
let re = Regex::new(&request.message_match_regex)
.map_err(|err| format!("invalid regex: {err}"))?;
@@ -257,6 +264,15 @@ fn declare_metric(
));
}
Ok(re)
}
fn declare_metric(
index: &mut BTreeMap<IndexKey, DataValue>,
request: DeclareMetricRequest,
) -> Result<(), String> {
let re = validate_metric_request(&request)?;
backfill_metric(
index,
&request.name,
@@ -277,6 +293,57 @@ fn declare_metric(
Ok(())
}
/// Updates the metric declaration named `existing_name` in place, reusing `declare_metric`'s validation so an edit is rejected the same way a bad new declaration would be. Fails (rather than creating a fresh declaration) if no metric named `existing_name` exists yet. Existing bucket/last-event data for this metric is cleared first and rebuilt from the full log history under the new regex/params, so edited matchers don't leave stale buckets from the previous declaration mixed in with fresh ones. Supports renaming (when `request.name` differs from `existing_name`) as long as the new name isn't already taken by another declaration.
fn update_metric(
index: &mut BTreeMap<IndexKey, DataValue>,
existing_name: &str,
request: DeclareMetricRequest,
) -> Result<(), String> {
if !index.contains_key(&IndexKey::MetricDeclaration { name: existing_name.to_string() }) {
return Err(format!("no metric declaration named '{existing_name}'"));
}
if request.name != existing_name
&& index.contains_key(&IndexKey::MetricDeclaration { name: request.name.clone() })
{
return Err(format!("a metric named '{}' already exists", request.name));
}
let re = validate_metric_request(&request)?;
clear_metric_data(index, existing_name);
if request.name != existing_name {
index.remove(&IndexKey::MetricDeclaration { name: existing_name.to_string() });
}
backfill_metric(
index,
&request.name,
&re,
&request.params,
request.matched_labels.as_ref(),
request.unmatched_labels.as_ref(),
);
index.insert(
IndexKey::MetricDeclaration { name: request.name.clone() },
DataValue::MetricDeclaration {
message_match_regex: request.message_match_regex,
params: request.params,
matched_labels: request.matched_labels,
unmatched_labels: request.unmatched_labels,
},
);
Ok(())
}
/// Removes every `Bucket` and `LastMetricEvent` entry belonging to `metric_name`, ahead of a full re-backfill in `update_metric` — otherwise the old declaration's buckets would sit alongside freshly backfilled ones under the same metric name.
fn clear_metric_data(index: &mut BTreeMap<IndexKey, DataValue>, metric_name: &str) {
index.retain(|key, _| match key {
IndexKey::Bucket { metric_name: name, .. } => name != metric_name,
IndexKey::LastMetricEvent { metric_name: name, .. } => name != metric_name,
_ => true,
});
}
/// 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 {
@@ -1319,6 +1386,126 @@ mod tests {
assert!(metric_declarations_range(&index).next().is_none());
}
#[test]
fn update_metric_rejects_when_no_declaration_exists_yet() {
let mut index = BTreeMap::new();
let result = update_metric(
&mut index,
"requests",
metric_request("requests", r"request path=(?P<path>\S+)", vec![]),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("requests"));
}
#[test]
fn update_metric_reuses_declare_metric_validation() {
let mut index = BTreeMap::new();
declare_metric(&mut index, metric_request("requests", r"request path=(?P<path>\S+)", vec![])).unwrap();
let result = update_metric(
&mut index,
"requests",
metric_request(
"requests",
r"request path=(?P<path>\S+)",
vec![("duration", "duration", MetricPrimitiveKind::Number)],
),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("duration"));
}
#[test]
fn update_metric_replaces_declaration_in_place_without_duplicating() {
let mut index = BTreeMap::new();
declare_metric(
&mut index,
metric_request(
"requests",
r"request path=(?P<path>\S+)",
vec![("path", "path", MetricPrimitiveKind::String)],
),
)
.unwrap();
let result = update_metric(
&mut index,
"requests",
metric_request(
"requests",
r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
vec![
("path", "path", MetricPrimitiveKind::String),
("duration", "duration", MetricPrimitiveKind::Number),
],
),
);
assert!(result.is_ok());
// Still one declaration named "requests", now with the updated regex/params.
let declarations: Vec<_> = metric_declarations_range(&index).collect();
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].0, "requests");
assert_eq!(declarations[0].2.len(), 2);
}
#[test]
fn update_metric_rebackfills_bucket_data_from_the_new_declaration() {
let mut index = BTreeMap::new();
ingest_log(&mut index, 0, "request path=/a");
ingest_log(&mut index, 1, "request path=/b");
declare_metric(
&mut index,
metric_request(
"requests",
r"request path=(?P<path>\S+)",
vec![("path", "path", MetricPrimitiveKind::String)],
),
)
.unwrap();
assert_eq!(bucket_values(&index, "requests", None).count(), 2);
// Editing to a narrower regex should leave exactly the buckets matching the *new* declaration — not the old ones layered underneath.
update_metric(
&mut index,
"requests",
metric_request(
"requests",
r"request path=(?P<path>/a)",
vec![("path", "path", MetricPrimitiveKind::String)],
),
)
.unwrap();
assert_eq!(bucket_values(&index, "requests", None).count(), 1);
}
#[test]
fn update_metric_can_rename_and_rejects_a_rename_onto_an_existing_name() {
let mut index = BTreeMap::new();
declare_metric(&mut index, metric_request("requests", r"request path=(?P<path>\S+)", vec![])).unwrap();
declare_metric(&mut index, metric_request("errors", r"error code=(?P<code>\d+)", vec![])).unwrap();
// Renaming onto an already-taken name is rejected.
let result = update_metric(
&mut index,
"requests",
metric_request("errors", r"request path=(?P<path>\S+)", vec![]),
);
assert!(result.is_err());
// Renaming onto a fresh name succeeds and drops the old declaration.
update_metric(
&mut index,
"requests",
metric_request("http_requests", r"request path=(?P<path>\S+)", vec![]),
)
.unwrap();
let names: Vec<_> = metric_declarations_range(&index).map(|(name, ..)| name.to_string()).collect();
assert!(names.contains(&"http_requests".to_string()));
assert!(!names.contains(&"requests".to_string()));
}
fn ingest_log(index: &mut BTreeMap<IndexKey, DataValue>, sequence: u64, text: &str) {
let timestamp = Timestamp::from_millis(sequence as i64);
index.insert(