diff --git a/src/api.rs b/src/api.rs index 3e4f280..a39e58f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -280,7 +280,8 @@ async fn declare_metric( let dto = MetricDeclarationDto { name: request.name, message_match_regex: request.message_match_regex, - params: request.params, + label_params: request.label_params, + value_param: request.value_param, matched_labels: request.matched_labels, unmatched_labels: request.unmatched_labels, }; @@ -313,7 +314,8 @@ async fn update_metric( let dto = MetricDeclarationDto { name: request.name, message_match_regex: request.message_match_regex, - params: request.params, + label_params: request.label_params, + value_param: request.value_param, matched_labels: request.matched_labels, unmatched_labels: request.unmatched_labels, }; @@ -906,9 +908,29 @@ mod tests { assert!(body["error"].as_str().unwrap().contains("invalid regex")); } + /// Declares a standalone param via `POST /api/params` — most metric-declaration tests need one + /// or more already-declared params to reference by name (ticket 037), rather than embedding a + /// regex/capture-group/kind inline in the metric request. + async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) { + let (status, _body) = send_json( + app, + "POST", + "/api/params", + json!({ + "name": name, + "message_match_regex": pattern, + "capture_group": capture_group, + "kind": kind, + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + } + #[tokio::test] async fn declares_metric_and_shows_up_in_list() { let (app, _engine, _settings, _dir) = test_app(); + declare_test_param(&app, "path", r"request path=(?P\S+)", "path", "string").await; let (status, body) = send_json( &app, "POST", @@ -916,9 +938,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "path", "capture_group": "path", "kind": "string" } - ] + "label_params": ["path"] }), ) .await; @@ -933,7 +953,7 @@ mod tests { } #[tokio::test] - async fn rejects_metric_with_unknown_capture_group() { + async fn rejects_metric_referencing_undeclared_param() { let (app, _engine, _settings, _dir) = test_app(); let (status, body) = send_json( &app, @@ -942,9 +962,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "duration", "capture_group": "duration", "kind": "number" } - ] + "label_params": ["duration"] }), ) .await; @@ -955,6 +973,8 @@ mod tests { #[tokio::test] async fn updates_metric_declaration_in_place_rather_than_duplicating() { let (app, _engine, _settings, _dir) = test_app(); + declare_test_param(&app, "path", r"request path=(?P\S+)", "path", "string").await; + declare_test_param(&app, "duration", r"duration=(?P\d+)", "duration", "number").await; send_json( &app, "POST", @@ -962,9 +982,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "path", "capture_group": "path", "kind": "string" } - ] + "label_params": ["path"] }), ) .await; @@ -976,23 +994,20 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+) duration=(?P\d+)", - "params": [ - { "param_name": "path", "capture_group": "path", "kind": "string" }, - { "param_name": "duration", "capture_group": "duration", "kind": "number" } - ] + "label_params": ["path", "duration"] }), ) .await; assert_eq!(status, StatusCode::OK); assert_eq!(body["name"], "requests"); - assert_eq!(body["params"].as_array().unwrap().len(), 2); + assert_eq!(body["label_params"].as_array().unwrap().len(), 2); // Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original. let (status, body) = get_json(&app, "/api/metrics").await; assert_eq!(status, StatusCode::OK); let metrics = body.as_array().unwrap(); assert_eq!(metrics.len(), 1); - assert_eq!(metrics[0]["params"].as_array().unwrap().len(), 2); + assert_eq!(metrics[0]["label_params"].as_array().unwrap().len(), 2); } #[tokio::test] @@ -1005,7 +1020,7 @@ mod tests { json!({ "name": "does-not-exist", "message_match_regex": r"request path=(?P\S+)", - "params": [] + "label_params": [] }), ) .await; @@ -1023,7 +1038,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [] + "label_params": [] }), ) .await; @@ -1035,9 +1050,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "duration", "capture_group": "duration", "kind": "number" } - ] + "label_params": ["duration"] }), ) .await; @@ -1049,6 +1062,7 @@ mod tests { async fn queries_bucket_values_with_label_filter_and_range() { let (app, engine, _settings, _dir) = test_app(); + declare_test_param(&app, "path", r"request path=(?P\S+)", "path", "string").await; send_json( &app, "POST", @@ -1056,9 +1070,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "path", "capture_group": "path", "kind": "string" } - ] + "label_params": ["path"] }), ) .await; @@ -1103,6 +1115,7 @@ mod tests { async fn promql_query_returns_series_for_seeded_fixture() { let (app, engine, _settings, _dir) = test_app(); + declare_test_param(&app, "path", r"request path=(?P\S+)", "path", "string").await; send_json( &app, "POST", @@ -1110,9 +1123,7 @@ mod tests { json!({ "name": "requests", "message_match_regex": r"request path=(?P\S+)", - "params": [ - { "param_name": "path", "capture_group": "path", "kind": "string" } - ] + "label_params": ["path"] }), ) .await; diff --git a/src/data_value.rs b/src/data_value.rs index 68105f9..18b44a6 100644 --- a/src/data_value.rs +++ b/src/data_value.rs @@ -3,29 +3,31 @@ use crate::timestamp::Timestamp; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegexToMetricPrimitiveMatcher { - pub param_name: String, +/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it +/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to +/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name +/// (`MetricDeclaration::label_params`/`value_param`) instead of redeclaring its own extractor per +/// param, so a param declared once (e.g. via the Params panel) can be reused as a label by one +/// metric and as the value by another without re-entering its regex/capture-group/kind. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ParamDeclaration { + pub name: String, + pub message_match_regex: String, pub capture_group: String, /// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g. /// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture /// group. When present and non-empty, this takes precedence over `capture_group`. /// `#[serde(default)]` so profile files/requests saved before this field existed still load - /// as plain regex-capture-group matchers. + /// as plain regex-capture-group params. #[serde(default)] pub json_field_path: Option, pub kind: MetricPrimitiveKind, - /// Whether this param is a bucket label or its captured value (at most one Value-role param per metric); `#[serde(default)]` so profile files saved before this field existed still load as `Label`. - #[serde(default)] - pub role: ParamRole, } /// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for -/// [`RegexToMetricPrimitiveMatcher::json_field_path`] and the sibling `json_field_path` on a -/// standalone param declaration. Returns `None` when `text` doesn't parse as a JSON object, the -/// path doesn't resolve to a value, or the resolved value isn't a scalar (string/number/bool) — -/// there's no sensible flat text form of a nested object/array to feed into +/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON +/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar +/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into /// `parse_metric_primitive`. pub fn extract_json_field(text: &str, field_path: &str) -> Option { let root: serde_json::Value = serde_json::from_str(text).ok()?; @@ -46,15 +48,6 @@ pub fn extract_json_field(text: &str, field_path: &str) -> Option { } } -/// See [`RegexToMetricPrimitiveMatcher::role`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ParamRole { - #[default] - Label, - Value, -} - /// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp". #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -63,7 +56,7 @@ pub enum MetricPrimitiveKind { String, Number, Timestamp, - /// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count. + /// On a metric's Value param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count. Duration, /// A standalone param declared with this kind doesn't require (or use) a capture group: its value is /// simply whether `message_match_regex` matches the line, case-insensitively or per the regex's own @@ -79,17 +72,15 @@ pub enum DataValue { /// Paired with `IndexKey::IngestedLog`. LogLiteral { text: String }, /// Paired with `IndexKey::ParamDeclaration`. - ParamDeclaration { - message_match_regex: String, - capture_group: String, - /// See `RegexToMetricPrimitiveMatcher::json_field_path`. - json_field_path: Option, - kind: MetricPrimitiveKind, - }, - /// Paired with `IndexKey::MetricDeclaration`. + ParamDeclaration(ParamDeclaration), + /// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone + /// `ParamDeclaration`s by name (resolved against the engine's param store in + /// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor. MetricDeclaration { message_match_regex: String, - params: Vec, + label_params: Vec, + /// At most one value param per metric; its primitive becomes the event's bucket value instead of a label. + value_param: Option, /// See `crate::engine::DeclareMetricRequest::matched_labels`. matched_labels: Option>, /// See `crate::engine::DeclareMetricRequest::unmatched_labels`. diff --git a/src/dto.rs b/src/dto.rs index d42be87..050d3a6 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -1,5 +1,5 @@ use crate::alerts::AlertCondition; -use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher}; +use crate::data_value::MetricPrimitiveKind; use crate::metric_primitive::MetricPrimitive; use crate::profile::{ChartType, Dashboard, Panel, Profile}; use crate::settings::{ColorScheme, CssEntry, Settings}; @@ -20,7 +20,7 @@ pub struct ParamDeclarationDto { pub name: String, pub message_match_regex: String, pub capture_group: String, - /// See `crate::data_value::RegexToMetricPrimitiveMatcher::json_field_path`. + /// See `crate::data_value::ParamDeclaration::json_field_path`. pub json_field_path: Option, pub kind: MetricPrimitiveKind, } @@ -29,7 +29,10 @@ pub struct ParamDeclarationDto { pub struct MetricDeclarationDto { pub name: String, pub message_match_regex: String, - pub params: Vec, + /// Names of standalone `ParamDeclaration`s used as this metric's bucket labels. + pub label_params: Vec, + /// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label. + pub value_param: Option, /// See `crate::engine::DeclareMetricRequest::matched_labels`. pub matched_labels: Option>, /// See `crate::engine::DeclareMetricRequest::unmatched_labels`. diff --git a/src/engine.rs b/src/engine.rs index 71f8144..7b2dd95 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,5 +1,5 @@ use crate::alerts::{self, AlertRuntimeState}; -use crate::data_value::{DataValue, MetricPrimitiveKind, ParamRole, RegexToMetricPrimitiveMatcher}; +use crate::data_value::{DataValue, MetricPrimitiveKind, ParamDeclaration}; use crate::dto::{ BucketValueDto, LogEntryDto, LogsParams, MetricDeclarationDto, ParamDeclarationDto, PromqlSeriesDto, StatsDto, TimelineBucketDto, TimelineParams, @@ -32,26 +32,24 @@ pub struct MetricSample { pub timestamp: Timestamp, } -/// Also the request body for `POST /api/params` — `Deserialize` is derived directly here rather than adding a separate DTO, since the shape already matches. -#[derive(Debug, Clone, Deserialize)] -pub struct DeclareParamRequest { - pub name: String, - pub message_match_regex: String, - pub capture_group: String, - /// See `RegexToMetricPrimitiveMatcher::json_field_path`; `#[serde(default)]` so existing - /// regex-only callers are unaffected. - #[serde(default)] - pub json_field_path: Option, - pub kind: MetricPrimitiveKind, -} +/// Also the request body for `POST /api/params` — `Deserialize` is derived directly here rather than adding a separate DTO, since the shape already matches `ParamDeclaration`. +pub type DeclareParamRequest = ParamDeclaration; -/// Params are derived purely from inline named capture groups on this metric's own regex — referencing an already-declared `ParamDeclaration` by name isn't supported yet. Also the request body for `POST /api/metrics` (hence `Deserialize`). +/// A metric references its params by name (resolved against the engine's standalone +/// `ParamDeclaration` store in `apply_metric_to_log`) rather than embedding its own +/// regex/capture-group/kind per param — see ticket 037. Also the request body for +/// `POST /api/metrics` (hence `Deserialize`). #[derive(Debug, Clone, Deserialize)] pub struct DeclareMetricRequest { pub name: String, pub message_match_regex: String, - pub params: Vec, - /// Fixed labels layered on top of what `params` captured, so e.g. a case-insensitive match doesn't fragment into "ERROR"/"Error" series; `#[serde(default)]` so existing declarations are unaffected. + /// Names of already-declared params whose values become this metric's bucket labels; `#[serde(default)]` so a label-less metric (e.g. matched/unmatched-labels-only) doesn't need an empty array. + #[serde(default)] + pub label_params: Vec, + /// Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. + #[serde(default)] + pub value_param: Option, + /// Fixed labels layered on top of what `label_params` captured, so e.g. a case-insensitive match doesn't fragment into "ERROR"/"Error" series; `#[serde(default)]` so existing declarations are unaffected. #[serde(default)] pub matched_labels: Option>, /// When a line doesn't match, registers one occurrence labeled with these fixed values instead of being invisible to this metric — plain regex alternation can't express "didn't match" without lookaround; `#[serde(default)]` so existing declarations keep matching-only behavior. @@ -234,48 +232,40 @@ fn declare_param( } index.insert( - IndexKey::ParamDeclaration { name: request.name }, - DataValue::ParamDeclaration { - message_match_regex: request.message_match_regex, - capture_group: request.capture_group, - json_field_path: request.json_field_path, - kind: request.kind, - }, + IndexKey::ParamDeclaration { name: request.name.clone() }, + DataValue::ParamDeclaration(request), ); Ok(()) } -/// 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 { +/// Validates the metric's own regex plus that every name in `label_params`/`value_param` refers to +/// an already-declared standalone param (not just the first bad one, so a rejection reports every +/// problem at once). Shared by `declare_metric` and `update_metric` so both reject a bad +/// declaration the same way. +fn validate_metric_request( + index: &BTreeMap, + request: &DeclareMetricRequest, +) -> Result { let re = Regex::new(&request.message_match_regex) .map_err(|err| format!("invalid regex: {err}"))?; - let errors: Vec = request - .params + let declared = |name: &str| index.contains_key(&IndexKey::ParamDeclaration { name: name.to_string() }); + + let mut errors: Vec = request + .label_params .iter() - .filter(|matcher| { - let uses_json_field = - matcher.json_field_path.as_deref().is_some_and(|path| !path.is_empty()); - !uses_json_field && !re.capture_names().flatten().any(|name| name == matcher.capture_group) - }) - .map(|matcher| { - format!( - "param '{}' references capture group '{}', which the regex does not have", - matcher.param_name, matcher.capture_group - ) - }) + .filter(|name| !declared(name)) + .map(|name| format!("label param '{name}' references an undeclared param")) .collect(); + if let Some(value_param) = &request.value_param { + if !declared(value_param) { + errors.push(format!("value param '{value_param}' references an undeclared param")); + } + } if !errors.is_empty() { return Err(errors.join("; ")); } - let value_params = request.params.iter().filter(|matcher| matcher.role == ParamRole::Value).count(); - if value_params > 1 { - return Err(format!( - "a metric may declare at most one Value-role param, found {value_params}" - )); - } - Ok(re) } @@ -283,13 +273,16 @@ fn declare_metric( index: &mut BTreeMap, request: DeclareMetricRequest, ) -> Result<(), String> { - let re = validate_metric_request(&request)?; + let re = validate_metric_request(index, &request)?; + let resolved_params = resolved_param_declarations(index); backfill_metric( index, &request.name, &re, - &request.params, + &request.label_params, + request.value_param.as_deref(), + &resolved_params, request.matched_labels.as_ref(), request.unmatched_labels.as_ref(), ); @@ -297,7 +290,8 @@ fn declare_metric( IndexKey::MetricDeclaration { name: request.name }, DataValue::MetricDeclaration { message_match_regex: request.message_match_regex, - params: request.params, + label_params: request.label_params, + value_param: request.value_param, matched_labels: request.matched_labels, unmatched_labels: request.unmatched_labels, }, @@ -320,7 +314,8 @@ fn update_metric( return Err(format!("a metric named '{}' already exists", request.name)); } - let re = validate_metric_request(&request)?; + let re = validate_metric_request(index, &request)?; + let resolved_params = resolved_param_declarations(index); clear_metric_data(index, existing_name); if request.name != existing_name { @@ -331,7 +326,9 @@ fn update_metric( index, &request.name, &re, - &request.params, + &request.label_params, + request.value_param.as_deref(), + &resolved_params, request.matched_labels.as_ref(), request.unmatched_labels.as_ref(), ); @@ -339,7 +336,8 @@ fn update_metric( IndexKey::MetricDeclaration { name: request.name.clone() }, DataValue::MetricDeclaration { message_match_regex: request.message_match_regex, - params: request.params, + label_params: request.label_params, + value_param: request.value_param, matched_labels: request.matched_labels, unmatched_labels: request.unmatched_labels, }, @@ -380,19 +378,75 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option, + kind: MetricPrimitiveKind, +} + +fn resolved_param_declarations(index: &BTreeMap) -> HashMap { + param_declarations_range(index) + .filter_map(|(name, message_match_regex, capture_group, json_field_path, kind)| { + Regex::new(message_match_regex).ok().map(|regex| { + ( + name.to_string(), + ResolvedParam { + regex, + capture_group: capture_group.to_string(), + json_field_path: json_field_path.map(|path| path.to_string()), + kind, + }, + ) + }) + }) + .collect() +} + +/// Extracts a param's raw captured text from `text` via its own regex (JSON field path taking +/// precedence when set) — shared by `apply_metric_to_log` (resolving a metric's `label_params`/ +/// `value_param`) and `derive_log_params` (a standalone param declaration shown as a logs-table +/// column). Returns `None` when the regex doesn't match, or (for a JSON field path) the field can't +/// be resolved. +fn extract_param_raw( + text: &str, + regex: &Regex, + capture_group: &str, + json_field_path: Option<&str>, +) -> Option { + match json_field_path.filter(|path| !path.is_empty()) { + Some(field_path) => crate::data_value::extract_json_field(text, field_path), + None => regex.captures(text)?.name(capture_group).map(|m| m.as_str().to_string()), + } +} + +/// Resolves `label_params`/`value_param` by name against `resolved_params` — a dangling reference +/// (a param removed after this metric was declared) skips the whole match, same as any other +/// extraction failure. At most one param is the value param (its primitive becomes the event's +/// value, otherwise a fixed `1` marker is used); any param failing to match/parse skips the whole +/// match rather than storing a partial one. A `Duration`-kind value param is elapsed time since the +/// previous same-labeled event, so the first occurrence pushes no bucket (nothing to diff against) +/// rather than a bogus zero. When `regex` (the metric's own message-match regex) doesn't match and +/// `unmatched_labels` is `Some`, an occurrence is still registered with those fixed labels. +#[allow(clippy::too_many_arguments)] fn apply_metric_to_log( index: &mut BTreeMap, metric_name: &str, regex: &Regex, - params: &[RegexToMetricPrimitiveMatcher], + label_params: &[String], + value_param: Option<&str>, + resolved_params: &HashMap, matched_labels: Option<&BTreeMap>, unmatched_labels: Option<&BTreeMap>, sequence: u64, timestamp: Timestamp, text: &str, ) { - let Some(captures) = regex.captures(text) else { + if regex.captures(text).is_none() { if let Some(unmatched_labels) = unmatched_labels { let labels = unmatched_labels .iter() @@ -401,38 +455,47 @@ fn apply_metric_to_log( push_bucket_value(index, metric_name, labels, sequence, timestamp, MetricPrimitive::Number(Number::Int(1))); } return; - }; + } let mut labels = BTreeMap::new(); - let mut value = None; - let mut is_duration = false; - for matcher in params { - let raw: String = match matcher.json_field_path.as_deref().filter(|path| !path.is_empty()) { - Some(field_path) => match crate::data_value::extract_json_field(text, field_path) { - Some(value) => value, - None => return, - }, - None => match captures.name(&matcher.capture_group) { - Some(m) => m.as_str().to_string(), - None => return, - }, - }; - if matcher.role == ParamRole::Value && matcher.kind == MetricPrimitiveKind::Duration { - is_duration = true; - continue; - } - let Some(parsed) = parse_metric_primitive(&raw, matcher.kind) else { + for param_name in label_params { + let Some(resolved) = resolved_params.get(param_name) else { return }; + let Some(raw) = extract_param_raw( + text, + &resolved.regex, + &resolved.capture_group, + resolved.json_field_path.as_deref(), + ) else { return; }; - match matcher.role { - ParamRole::Label => { - labels.insert(matcher.param_name.clone(), parsed); - } - ParamRole::Value => { - value = Some(parsed); - } + let Some(parsed) = parse_metric_primitive(&raw, resolved.kind) else { + return; + }; + labels.insert(param_name.clone(), parsed); + } + + let mut value = None; + let mut is_duration = false; + if let Some(value_param) = value_param { + let Some(resolved) = resolved_params.get(value_param) else { return }; + if resolved.kind == MetricPrimitiveKind::Duration { + is_duration = true; + } else { + let Some(raw) = extract_param_raw( + text, + &resolved.regex, + &resolved.capture_group, + resolved.json_field_path.as_deref(), + ) else { + return; + }; + let Some(parsed) = parse_metric_primitive(&raw, resolved.kind) else { + return; + }; + value = Some(parsed); } } + if let Some(matched_labels) = matched_labels { for (name, value) in matched_labels { labels.insert(name.clone(), MetricPrimitive::String(value.clone())); @@ -479,11 +542,14 @@ fn push_bucket_value( } /// Backfills buckets for a newly-declared metric against every already-ingested log, not just future ones. +#[allow(clippy::too_many_arguments)] fn backfill_metric( index: &mut BTreeMap, metric_name: &str, regex: &Regex, - params: &[RegexToMetricPrimitiveMatcher], + label_params: &[String], + value_param: Option<&str>, + resolved_params: &HashMap, matched_labels: Option<&BTreeMap>, unmatched_labels: Option<&BTreeMap>, ) { @@ -495,7 +561,9 @@ fn backfill_metric( index, metric_name, regex, - params, + label_params, + value_param, + resolved_params, matched_labels, unmatched_labels, sequence, @@ -514,28 +582,34 @@ fn apply_all_metrics( type SeededMetric = ( String, String, - Vec, + Vec, + Option, Option>, Option>, ); let metrics: Vec = metric_declarations_range(index) - .map(|(name, message_match_regex, params, matched_labels, unmatched_labels)| { + .map(|(name, message_match_regex, label_params, value_param, matched_labels, unmatched_labels)| { ( name.to_string(), message_match_regex.to_string(), - params.to_vec(), + label_params.to_vec(), + value_param.map(|s| s.to_string()), matched_labels.cloned(), unmatched_labels.cloned(), ) }) .collect(); - for (name, message_match_regex, params, matched_labels, unmatched_labels) in metrics { + // Resolved once per ingested line (shared across every metric that runs against it), not once per metric. + let resolved_params = resolved_param_declarations(index); + for (name, message_match_regex, label_params, value_param, matched_labels, unmatched_labels) in metrics { if let Ok(re) = Regex::new(&message_match_regex) { apply_metric_to_log( index, &name, &re, - ¶ms, + &label_params, + value_param.as_deref(), + &resolved_params, matched_labels.as_ref(), unmatched_labels.as_ref(), sequence, @@ -818,7 +892,8 @@ fn metric_declarations_range( Item = ( &str, &str, - &[RegexToMetricPrimitiveMatcher], + &[String], + Option<&str>, Option<&BTreeMap>, Option<&BTreeMap>, ), @@ -828,11 +903,18 @@ fn metric_declarations_range( .filter_map(|(key, value)| match (key, value) { ( IndexKey::MetricDeclaration { name }, - DataValue::MetricDeclaration { message_match_regex, params, matched_labels, unmatched_labels }, + DataValue::MetricDeclaration { + message_match_regex, + label_params, + value_param, + matched_labels, + unmatched_labels, + }, ) => Some(( name.as_str(), message_match_regex.as_str(), - params.as_slice(), + label_params.as_slice(), + value_param.as_deref(), matched_labels.as_ref(), unmatched_labels.as_ref(), )), @@ -848,7 +930,13 @@ fn param_declarations_range( .filter_map(|(key, value)| match (key, value) { ( IndexKey::ParamDeclaration { name }, - DataValue::ParamDeclaration { message_match_regex, capture_group, json_field_path, kind }, + DataValue::ParamDeclaration(ParamDeclaration { + message_match_regex, + capture_group, + json_field_path, + kind, + .. + }), ) => Some(( name.as_str(), message_match_regex.as_str(), @@ -1065,12 +1153,15 @@ fn handle_query( } EngineQuery::MetricDeclarations => { let declarations = metric_declarations_range(index) - .map(|(name, message_match_regex, params, matched_labels, unmatched_labels)| MetricDeclarationDto { - name: name.to_string(), - message_match_regex: message_match_regex.to_string(), - params: params.to_vec(), - matched_labels: matched_labels.cloned(), - unmatched_labels: unmatched_labels.cloned(), + .map(|(name, message_match_regex, label_params, value_param, matched_labels, unmatched_labels)| { + MetricDeclarationDto { + name: name.to_string(), + message_match_regex: message_match_regex.to_string(), + label_params: label_params.to_vec(), + value_param: value_param.map(|s| s.to_string()), + matched_labels: matched_labels.cloned(), + unmatched_labels: unmatched_labels.cloned(), + } }) .collect(); EngineResponse::MetricDeclarations(declarations) @@ -1485,7 +1576,8 @@ mod tests { IndexKey::MetricDeclaration { name: "requests".to_string() }, DataValue::MetricDeclaration { message_match_regex: "x".to_string(), - params: vec![], + label_params: vec![], + value_param: None, matched_labels: None, unmatched_labels: None, }, @@ -1508,67 +1600,63 @@ mod tests { assert!(param_declarations_range(&index).next().is_none()); } - fn metric_request( + /// Declares a standalone param whose extractor points at `pattern`'s named `capture_group` — the + /// shape most of these tests need so a metric can then reference it by name via `label_params`/ + /// `value_param` instead of embedding its own extractor (ticket 037). + fn declare_test_param( + index: &mut BTreeMap, name: &str, pattern: &str, - params: Vec<(&str, &str, MetricPrimitiveKind)>, - ) -> DeclareMetricRequest { - metric_request_with_roles( - name, - pattern, - params - .into_iter() - .map(|(param_name, capture_group, kind)| (param_name, capture_group, kind, ParamRole::Label)) - .collect(), + capture_group: &str, + kind: MetricPrimitiveKind, + ) { + declare_param( + index, + DeclareParamRequest { + name: name.to_string(), + message_match_regex: pattern.to_string(), + capture_group: capture_group.to_string(), + json_field_path: None, + kind, + }, ) + .unwrap(); } - fn metric_request_with_roles( + fn metric_request(name: &str, pattern: &str, label_params: Vec<&str>) -> DeclareMetricRequest { + metric_request_with_value(name, pattern, label_params, None) + } + + fn metric_request_with_value( name: &str, pattern: &str, - params: Vec<(&str, &str, MetricPrimitiveKind, ParamRole)>, + label_params: Vec<&str>, + value_param: Option<&str>, ) -> DeclareMetricRequest { DeclareMetricRequest { name: name.to_string(), message_match_regex: pattern.to_string(), + label_params: label_params.into_iter().map(str::to_string).collect(), + value_param: value_param.map(str::to_string), matched_labels: None, unmatched_labels: None, - params: params - .into_iter() - .map(|(param_name, capture_group, kind, role)| RegexToMetricPrimitiveMatcher { - param_name: param_name.to_string(), - capture_group: capture_group.to_string(), - json_field_path: None, - kind, - role, - }) - .collect(), } } #[test] fn declares_valid_metric() { let mut index = BTreeMap::new(); - let result = declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+) duration=(?P\d+)", - vec![ - ("path", "path", MetricPrimitiveKind::String), - ("duration", "duration", MetricPrimitiveKind::Number), - ], - ), - ); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); + + let result = declare_metric(&mut index, metric_request("requests", pattern, vec!["path", "duration"])); assert!(result.is_ok()); let declarations: Vec<_> = metric_declarations_range(&index).collect(); assert_eq!(declarations.len(), 1); assert_eq!(declarations[0].0, "requests"); - assert_eq!( - declarations[0].1, - r"request path=(?P\S+) duration=(?P\d+)" - ); + assert_eq!(declarations[0].1, pattern); assert_eq!(declarations[0].2.len(), 2); } @@ -1581,23 +1669,16 @@ mod tests { } #[test] - fn rejects_metric_matcher_with_unknown_capture_group() { + fn rejects_metric_referencing_undeclared_param() { let mut index = BTreeMap::new(); - let result = declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![ - ("path", "path", MetricPrimitiveKind::String), - ("duration", "duration", MetricPrimitiveKind::Number), - ], - ), - ); + let pattern = r"request path=(?P\S+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + + // "duration" is never declared as a standalone param. + let result = declare_metric(&mut index, metric_request("requests", pattern, vec!["path", "duration"])); assert!(result.is_err()); - // Both matchers are validated: the error mentions the one that's actually bad. assert!(result.unwrap_err().contains("duration")); - assert!(index.is_empty()); + assert!(!index.contains_key(&IndexKey::MetricDeclaration { name: "requests".to_string() })); } #[test] @@ -1608,15 +1689,9 @@ mod tests { DataValue::LogLiteral { text: "hello".to_string() }, ); declare_param(&mut index, request("level", r"level=(?P\w+)", "level")).unwrap(); - declare_metric( - &mut index, - metric_request( - "requests", - r"duration=(?P\d+)", - vec![("duration", "duration", MetricPrimitiveKind::Number)], - ), - ) - .unwrap(); + let pattern = r"duration=(?P\d+)"; + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); + declare_metric(&mut index, metric_request("requests", pattern, vec!["duration"])).unwrap(); let declarations: Vec<_> = metric_declarations_range(&index).collect(); assert_eq!(declarations.len(), 1); @@ -1630,15 +1705,9 @@ mod tests { #[test] fn removes_metric_declaration() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"duration=(?P\d+)", - vec![("duration", "duration", MetricPrimitiveKind::Number)], - ), - ) - .unwrap(); + let pattern = r"duration=(?P\d+)"; + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); + declare_metric(&mut index, metric_request("requests", pattern, vec!["duration"])).unwrap(); assert!(index.remove(&IndexKey::MetricDeclaration { name: "requests".to_string() }).is_some()); assert!(metric_declarations_range(&index).next().is_none()); } @@ -1660,14 +1729,11 @@ mod tests { let mut index = BTreeMap::new(); declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec![])).unwrap(); + // "duration" is never declared as a standalone param. let result = update_metric( &mut index, "requests", - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("duration", "duration", MetricPrimitiveKind::Number)], - ), + metric_request("requests", r"request path=(?P\S+)", vec!["duration"]), ); assert!(result.is_err()); assert!(result.unwrap_err().contains("duration")); @@ -1676,28 +1742,12 @@ mod tests { #[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\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); - let result = update_metric( - &mut index, - "requests", - metric_request( - "requests", - r"request path=(?P\S+) duration=(?P\d+)", - vec![ - ("path", "path", MetricPrimitiveKind::String), - ("duration", "duration", MetricPrimitiveKind::Number), - ], - ), - ); + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); + let result = update_metric(&mut index, "requests", metric_request("requests", pattern, vec!["path", "duration"])); assert!(result.is_ok()); // Still one declaration named "requests", now with the updated regex/params. @@ -1712,28 +1762,12 @@ mod tests { 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\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(&mut index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).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/a)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + // Editing to a narrower regex should leave exactly the buckets matching the *new* declaration — not the old ones layered underneath; the "path" param's own regex is unaffected, so it still extracts fine. + update_metric(&mut index, "requests", metric_request("requests", r"request path=(?P/a)", vec!["path"])).unwrap(); assert_eq!(bucket_values(&index, "requests", None).count(), 1); } @@ -1779,15 +1813,9 @@ mod tests { ingest_log(&mut index, 1, "request path=/b duration=2"); ingest_log(&mut index, 2, "unrelated line"); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+) duration=(?P\d+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", pattern, vec!["path"])).unwrap(); let values: Vec<_> = bucket_values(&index, "requests", None).collect(); assert_eq!(values.len(), 2); @@ -1802,23 +1830,18 @@ mod tests { // Parses as JSON but is missing the field — also skipped. ingest_log(&mut index, 2, r#"{"request":{}}"#); - declare_metric( + declare_param( &mut index, - DeclareMetricRequest { - name: "requests".to_string(), + DeclareParamRequest { + name: "path".to_string(), message_match_regex: r".".to_string(), - matched_labels: None, - unmatched_labels: None, - params: vec![RegexToMetricPrimitiveMatcher { - param_name: "path".to_string(), - capture_group: String::new(), - json_field_path: Some("request.path".to_string()), - kind: MetricPrimitiveKind::String, - role: ParamRole::Label, - }], + capture_group: String::new(), + json_field_path: Some("request.path".to_string()), + kind: MetricPrimitiveKind::String, }, ) .unwrap(); + declare_metric(&mut index, metric_request("requests", r".", vec!["path"])).unwrap(); let values: Vec<_> = bucket_values(&index, "requests", None).collect(); assert_eq!(values.len(), 1); @@ -1831,15 +1854,8 @@ mod tests { #[test] fn ingesting_after_declaration_updates_buckets_live() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(&mut index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); ingest_log(&mut index, 0, "request path=/a"); @@ -1851,15 +1867,8 @@ mod tests { #[test] fn non_matching_log_leaves_bucket_range_untouched() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(&mut index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); ingest_log(&mut index, 0, "totally unrelated line"); @@ -1874,15 +1883,8 @@ mod tests { #[test] fn bucket_values_preserve_insertion_order() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(&mut index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); ingest_log(&mut index, 5, "request path=/a"); ingest_log(&mut index, 2, "request path=/a"); @@ -1897,15 +1899,8 @@ mod tests { #[test] fn differing_label_combinations_produce_distinct_buckets() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(&mut index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); ingest_log(&mut index, 0, "request path=/a"); ingest_log(&mut index, 1, "request path=/b"); @@ -1926,24 +1921,11 @@ mod tests { #[test] fn single_log_line_can_match_multiple_metrics() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"request path=(?P\S+) duration=(?P\d+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); - declare_metric( - &mut index, - metric_request( - "durations", - r"request path=(?P\S+) duration=(?P\d+)", - vec![("duration", "duration", MetricPrimitiveKind::Number)], - ), - ) - .unwrap(); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); + declare_metric(&mut index, metric_request("requests", pattern, vec!["path"])).unwrap(); + declare_metric(&mut index, metric_request("durations", pattern, vec!["duration"])).unwrap(); ingest_log(&mut index, 0, "request path=/a duration=5"); @@ -1952,15 +1934,8 @@ mod tests { } fn declare_requests_metric(index: &mut BTreeMap) { - declare_metric( - index, - metric_request( - "requests", - r"request path=(?P\S+)", - vec![("path", "path", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + declare_test_param(index, "path", r"request path=(?P\S+)", "path", MetricPrimitiveKind::String); + declare_metric(index, metric_request("requests", r"request path=(?P\S+)", vec!["path"])).unwrap(); } #[test] @@ -2089,53 +2064,31 @@ mod tests { } #[test] - fn declares_metric_with_value_role_param() { + fn declares_metric_with_value_param() { let mut index = BTreeMap::new(); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); let result = declare_metric( &mut index, - metric_request_with_roles( - "durations", - r"request path=(?P\S+) duration=(?P\d+)", - vec![ - ("path", "path", MetricPrimitiveKind::String, ParamRole::Label), - ("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value), - ], - ), + metric_request_with_value("durations", pattern, vec!["path"], Some("duration")), ); assert!(result.is_ok()); } - #[test] - fn rejects_metric_with_multiple_value_role_params() { - let mut index = BTreeMap::new(); - let result = declare_metric( - &mut index, - metric_request_with_roles( - "bad", - r"a=(?P\d+) b=(?P\d+)", - vec![ - ("a", "a", MetricPrimitiveKind::Number, ParamRole::Value), - ("b", "b", MetricPrimitiveKind::Number, ParamRole::Value), - ], - ), - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("at most one Value-role param")); - } + // A metric may declare at most one value param — enforced at the type level now + // (`DeclareMetricRequest::value_param` is a single `Option`, not a list of + // role-tagged params), so there's no longer a runtime "too many value params" rejection to test. #[test] - fn value_role_param_becomes_the_bucket_value_not_a_label() { + fn value_param_becomes_the_bucket_value_not_a_label() { let mut index = BTreeMap::new(); + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(&mut index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_test_param(&mut index, "duration", pattern, "duration", MetricPrimitiveKind::Number); declare_metric( &mut index, - metric_request_with_roles( - "durations", - r"request path=(?P\S+) duration=(?P\d+)", - vec![ - ("path", "path", MetricPrimitiveKind::String, ParamRole::Label), - ("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value), - ], - ), + metric_request_with_value("durations", pattern, vec!["path"], Some("duration")), ) .unwrap(); @@ -2150,16 +2103,12 @@ mod tests { } fn declare_durations_metric(index: &mut BTreeMap) { + let pattern = r"request path=(?P\S+) duration=(?P\d+)"; + declare_test_param(index, "path", pattern, "path", MetricPrimitiveKind::String); + declare_test_param(index, "duration", pattern, "duration", MetricPrimitiveKind::Number); declare_metric( index, - metric_request_with_roles( - "durations", - r"request path=(?P\S+) duration=(?P\d+)", - vec![ - ("path", "path", MetricPrimitiveKind::String, ParamRole::Label), - ("duration", "duration", MetricPrimitiveKind::Number, ParamRole::Value), - ], - ), + metric_request_with_value("durations", pattern, vec!["path"], Some("duration")), ) .unwrap(); } @@ -2221,18 +2170,13 @@ mod tests { } fn declare_login_duration_metric(index: &mut BTreeMap) { - declare_metric( - index, - metric_request_with_roles( - "login_gap", - r"user=(?P\S+) logged in", - vec![ - ("user", "user", MetricPrimitiveKind::String, ParamRole::Label), - ("gap", "user", MetricPrimitiveKind::Duration, ParamRole::Value), - ], - ), - ) - .unwrap(); + let pattern = r"user=(?P\S+) logged in"; + declare_test_param(index, "user", pattern, "user", MetricPrimitiveKind::String); + // A Duration-kind value param never actually extracts via its own capture group (see + // `apply_metric_to_log`) — its regex/capture-group only need to satisfy `declare_param`'s + // validation, so reusing "user" here is harmless. + declare_test_param(index, "gap", pattern, "user", MetricPrimitiveKind::Duration); + declare_metric(index, metric_request_with_value("login_gap", pattern, vec!["user"], Some("gap"))).unwrap(); } #[test] @@ -2291,7 +2235,8 @@ mod tests { DeclareMetricRequest { name: "log_category".to_string(), message_match_regex: r"(?i)error".to_string(), - params: vec![], + label_params: vec![], + value_param: None, matched_labels: Some(BTreeMap::from([("category".to_string(), "error".to_string())])), unmatched_labels: Some(BTreeMap::from([("category".to_string(), "other".to_string())])), }, @@ -2488,15 +2433,9 @@ mod tests { #[test] fn logs_query_combines_regex_and_metric_comparison_with_and() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"level=(?P\w+) duration=(?P\d+)", - vec![("level", "level", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + let pattern = r"level=(?P\w+) duration=(?P\d+)"; + declare_test_param(&mut index, "level", pattern, "level", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", pattern, vec!["level"])).unwrap(); ingest_log(&mut index, 0, "level=error duration=1 boom"); ingest_log(&mut index, 1, "level=info duration=2 boom"); @@ -2602,15 +2541,9 @@ mod tests { #[test] fn metric_comparison_without_param_is_a_compile_error() { let mut index = BTreeMap::new(); - declare_metric( - &mut index, - metric_request( - "requests", - r"level=(?P\w+)", - vec![("level", "level", MetricPrimitiveKind::String)], - ), - ) - .unwrap(); + let pattern = r"level=(?P\w+)"; + declare_test_param(&mut index, "level", pattern, "level", MetricPrimitiveKind::String); + declare_metric(&mut index, metric_request("requests", pattern, vec!["level"])).unwrap(); ingest_log(&mut index, 0, "level=error"); match handle_query(&index, logs_query(Some("requests==5")), 0) { diff --git a/src/profile.rs b/src/profile.rs index 4ef2c81..e52bb2a 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -13,7 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; -use crate::data_value::{MetricPrimitiveKind, ParamRole, RegexToMetricPrimitiveMatcher}; +use crate::data_value::MetricPrimitiveKind; use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage}; use crate::settings::Settings; use tokio::sync::oneshot; @@ -58,13 +58,13 @@ impl From for MetricPrimitiveKind { } } -/// A stored param declaration, matching `DeclareParamRequest`'s shape. +/// 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::RegexToMetricPrimitiveMatcher::json_field_path`; `#[serde(default)]` + /// 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, @@ -95,33 +95,56 @@ impl From<&ParamDeclarationRecord> for DeclareParamRequest { } } -/// A stored metric param matcher, matching `RegexToMetricPrimitiveMatcher`'s shape. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MetricParamRecord { - pub param_name: String, - pub capture_group: String, - /// See `crate::data_value::RegexToMetricPrimitiveMatcher::json_field_path`; `#[serde(default)]` - /// so profiles saved before this field existed still load as plain regex-capture-group matchers. - #[serde(default)] - pub json_field_path: Option, - pub kind: ProfileMetricPrimitiveKind, - /// See `ParamRole`; `#[serde(default)]` so profiles saved before this field existed still load, defaulting to `Label`. - #[serde(default)] - pub role: ParamRole, +/// 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, } -/// A stored metric declaration, matching `DeclareMetricRequest`'s shape. +/// 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, + 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, - pub params: Vec, + /// Names of standalone `ParamDeclarationRecord`s used as this metric's bucket labels. + #[serde(default)] + pub label_params: Vec, + /// Name of the standalone `ParamDeclarationRecord` (if any) whose value becomes this metric's bucket value. + #[serde(default)] + pub value_param: Option, /// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load. #[serde(default)] pub matched_labels: Option>, /// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load. #[serde(default)] pub unmatched_labels: Option>, + #[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")] + legacy_params: Vec, } impl From<&DeclareMetricRequest> for MetricDeclarationRecord { @@ -129,19 +152,11 @@ impl From<&DeclareMetricRequest> for MetricDeclarationRecord { MetricDeclarationRecord { name: request.name.clone(), message_match_regex: request.message_match_regex.clone(), - params: request - .params - .iter() - .map(|matcher| MetricParamRecord { - param_name: matcher.param_name.clone(), - capture_group: matcher.capture_group.clone(), - json_field_path: matcher.json_field_path.clone(), - kind: matcher.kind.into(), - role: matcher.role, - }) - .collect(), + 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(), } } } @@ -151,23 +166,81 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest { 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(), - params: record - .params - .iter() - .map(|param| RegexToMetricPrimitiveMatcher { - param_name: param.param_name.clone(), - capture_group: param.capture_group.clone(), - json_field_path: param.json_field_path.clone(), - kind: param.kind.into(), - role: param.role, - }) - .collect(), } } } +/// 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 `.` (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 `.` 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, + }); + } + implicit_name +} + /// 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")] @@ -223,9 +296,11 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord { MetricDeclarationRecord { name: SEEDED_METRIC_NAME.to_string(), message_match_regex: r"(?i)error".to_string(), - params: Vec::new(), + 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(), } } @@ -384,7 +459,7 @@ pub struct ProfileStore { impl ProfileStore { /// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup. pub fn load(store_path: &Path) -> ProfileStore { - match fs::read_to_string(store_path) { + let mut store = match fs::read_to_string(store_path) { Ok(contents) => match serde_json::from_str::>(&contents) { Ok(profiles) => ProfileStore { profiles }, Err(err) => { @@ -403,7 +478,29 @@ impl ProfileStore { ); ProfileStore::default() } + }; + + // 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. + let mut any_migrated = false; + for profile in &mut store.profiles { + if migrate_legacy_metric_params(profile) { + any_migrated = true; + } } + if any_migrated { + if let Err(err) = store.save(store_path) { + eprintln!( + "warning: failed to persist migrated profile store at {}: {err}", + store_path.display() + ); + } + } + + store } /// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first. @@ -672,19 +769,14 @@ mod tests { } } - fn metric_request(name: &str, pattern: &str, param_name: &str, capture_group: &str) -> DeclareMetricRequest { + 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, - params: vec![RegexToMetricPrimitiveMatcher { - param_name: param_name.to_string(), - capture_group: capture_group.to_string(), - json_field_path: None, - kind: MetricPrimitiveKind::Number, - role: ParamRole::Label, - }], + label_params: vec![param_name.to_string()], + value_param: None, } } @@ -938,12 +1030,7 @@ mod tests { let param_req = param_request("level", r"level=(?P\w+)", "level"); store.record_param_declaration(&store_path, "web", ¶m_req).unwrap(); - let metric_req = metric_request( - "requests", - r"duration=(?P\d+)", - "duration", - "duration", - ); + let metric_req = metric_request("requests", r"duration=(?P\d+)", "duration"); store.record_metric_declaration(&store_path, "web", &metric_req).unwrap(); let profile = store.get("web").unwrap(); @@ -984,16 +1071,20 @@ mod tests { ¶m_request("level", r"level=(?P\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. + store + .record_param_declaration( + &store_path, + "web", + ¶m_request("duration", r"request duration=(?P\d+)", "duration"), + ) + .unwrap(); store .record_metric_declaration( &store_path, "web", - &metric_request( - "requests", - r"request duration=(?P\d+)", - "duration", - "duration", - ), + &metric_request("requests", r"request duration=(?P\d+)", "duration"), ) .unwrap(); store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap(); @@ -1026,11 +1117,11 @@ mod tests { EngineResponse::ParamDeclarations(params) => params, _ => panic!("unexpected response"), }; - assert_eq!(params.len(), 1); - assert_eq!(params[0].name, "level"); - assert_eq!(params[0].message_match_regex, r"level=(?P\w+)"); - assert_eq!(params[0].capture_group, "level"); - assert!(matches!(params[0].kind, MetricPrimitiveKind::String)); + assert_eq!(params.len(), 2); + let level = params.iter().find(|p| p.name == "level").unwrap(); + assert_eq!(level.message_match_regex, r"level=(?P\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, @@ -1039,8 +1130,7 @@ mod tests { assert_eq!(metrics.len(), 1); assert_eq!(metrics[0].name, "requests"); assert_eq!(metrics[0].message_match_regex, r"request duration=(?P\d+)"); - assert_eq!(metrics[0].params.len(), 1); - assert_eq!(metrics[0].params[0].param_name, "duration"); + assert_eq!(metrics[0].label_params, vec!["duration".to_string()]); let logs = match ask( &engine_tx, @@ -1111,4 +1201,149 @@ mod tests { Err(ProfileError::NotFound) )); } + + /// Writes a pre-037 `profiles.json` (metrics carrying inline `"params"` matchers rather than + /// `label_params`/`value_param`) directly to disk, bypassing `ProfileStore`/`Profile` entirely + /// so this doesn't accidentally exercise the current (post-migration) shape. + fn write_legacy_profiles_json(store_path: &Path, contents: &str) { + fs::create_dir_all(store_path.parent().unwrap()).unwrap(); + fs::write(store_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 store_path = dir.path().join("profiles.json"); + write_legacy_profiles_json( + &store_path, + r#"[ + { + "name": "legacy", + "params": [], + "metrics": [ + { + "name": "requests", + "message_match_regex": "request path=(?P\\S+) duration=(?P\\d+)", + "params": [ + { "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" }, + { "param_name": "duration", "capture_group": "duration", "kind": "number", "role": "value" } + ] + } + ], + "dashboards": [] + } + ]"#, + ); + + let store = ProfileStore::load(&store_path); + let profile = store.get("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(&store_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 (engine_tx, engine_rx) = mpsc::channel(16); + let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default())); + tokio::runtime::Runtime::new().unwrap().block_on(async { + tokio::spawn(crate::engine::run(engine_rx, settings_handle)); + load_into_engine(&engine_tx, profile).await; + engine_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()]); + }); + } + + /// 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 `.` declaration. + #[test] + fn migration_reuses_an_existing_matching_bare_named_param() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("profiles.json"); + write_legacy_profiles_json( + &store_path, + r#"[ + { + "name": "legacy", + "params": [ + { "name": "path", "message_match_regex": "request path=(?P\\S+)", "capture_group": "path", "kind": "string" } + ], + "metrics": [ + { + "name": "requests", + "message_match_regex": "request path=(?P\\S+)", + "params": [ + { "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" } + ] + } + ], + "dashboards": [] + } + ]"#, + ); + + let store = ProfileStore::load(&store_path); + let profile = store.get("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 store_path = dir.path().join("profiles.json"); + let mut settings = Settings::default(); + let mut store = ProfileStore::load(&store_path); + store.create(&store_path, &mut settings, &dir.path().join("settings.json"), "web".to_string()).unwrap(); + store + .record_param_declaration( + &store_path, + "web", + ¶m_request("path", r"path=(?P\S+)", "path"), + ) + .unwrap(); + store + .record_metric_declaration(&store_path, "web", &metric_request("requests", r"path=(?P\S+)", "path")) + .unwrap(); + + let before = store.get("web").unwrap().clone(); + let reloaded = ProfileStore::load(&store_path); + let after = reloaded.get("web").unwrap().clone(); + assert_eq!(before, after); + } } diff --git a/webapp/src/components/Metrics.tsx b/webapp/src/components/Metrics.tsx index 0479d45..0573d34 100644 --- a/webapp/src/components/Metrics.tsx +++ b/webapp/src/components/Metrics.tsx @@ -5,11 +5,12 @@ import { declareMetric, fetchMetricBucketValues, fetchMetricDeclarations, + fetchParamDeclarations, metricPrimitiveToDisplayString, updateMetric, type BucketValue, type MetricDeclaration, - type MetricParamMatcher, + type ParamDeclaration, } from '../lib/api' import { CollapseToggle, @@ -22,8 +23,6 @@ import { import { HelpIcon } from './HelpIcon' import { RegexHelpIcon } from './RegexHelpIcon' -const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' } - // One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering. interface LabelGroup { key: string @@ -80,12 +79,16 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string { export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) { const [metrics, setMetrics] = useState([]) + const [params, setParams] = useState([]) const [selected, setSelected] = useState(null) const [bucketValues, setBucketValues] = useState([]) const [name, setName] = useState('') const [regex, setRegex] = useState('') - const [matchers, setMatchers] = useState([{ ...EMPTY_MATCHER }]) + // A metric picks its label/value params from the params already declared in the Params panel + // (ticket 037) rather than re-entering a regex/capture-group/kind per param. + const [labelParams, setLabelParams] = useState([]) + const [valueParam, setValueParam] = useState('') const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) const [collapsed, setCollapsed] = useSectionCollapsed('metrics') @@ -101,8 +104,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti } } + async function loadParams() { + try { + setParams(await fetchParamDeclarations()) + } catch { + // engine not reachable yet; leave the previous list in place + } + } + useEffect(() => { loadMetrics() + loadParams() }, []) // Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling. @@ -124,23 +136,18 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti } }, [selected, range]) - function updateMatcher(index: number, patch: Partial) { - setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m))) - } - - function addMatcher() { - setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }]) - } - - function removeMatcher(index: number) { - setMatchers((prev) => prev.filter((_, i) => i !== index)) + function toggleLabelParam(paramName: string) { + setLabelParams((prev) => + prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName], + ) } function resetForm() { setEditingName(null) setName('') setRegex('') - setMatchers([{ ...EMPTY_MATCHER }]) + setLabelParams([]) + setValueParam('') setSubmitError(null) } @@ -149,7 +156,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti setEditingName(metric.name) setName(metric.name) setRegex(metric.message_match_regex) - setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }]) + setLabelParams(metric.label_params) + setValueParam(metric.value_param ?? '') setSubmitError(null) } @@ -166,7 +174,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti const request: MetricDeclaration = { name, message_match_regex: regex, - params: matchers.filter((m) => m.param_name && m.capture_group), + label_params: labelParams, + value_param: valueParam || null, } const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request) resetForm() @@ -227,61 +236,49 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti />
-
- Params - + +
+ {params.length === 0 ? ( + No params declared yet. + ) : ( + params.map((p) => ( + + )) + )}
-
- {matchers.map((matcher, i) => ( -
- updateMatcher(i, { param_name: e.target.value })} - placeholder="param_name" - /> - updateMatcher(i, { capture_group: e.target.value })} - placeholder="capture_group" - /> - - - -
+
+
+ +
{editingName && (
@@ -325,7 +322,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti }`} title={m.message_match_regex} > - {m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'}) + {m.name} ({m.label_params.join(', ') || 'no params'}) ))}
@@ -342,17 +339,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti {selectedMetric.message_match_regex}
- Params: - {selectedMetric.params.length === 0 ? ( + Label params: + {selectedMetric.label_params.length === 0 ? ( none ) : ( - - {selectedMetric.params - .map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`) - .join(', ')} - + {selectedMetric.label_params.join(', ')} )}
+
+ Value param: + {selectedMetric.value_param ?? none} +
)} {groups.length === 0 ? ( diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 0548705..2fb49d5 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -47,22 +47,13 @@ export interface Stats { approx_bytes_total: number } -export type ParamRole = 'label' | 'value' - -export interface MetricParamMatcher { - param_name: string - capture_group: string - /** See `ParamDeclaration.json_field_path`. */ - json_field_path?: string | null - kind: 'string' | 'number' | 'timestamp' | 'duration' - /** Defaults to 'label' server-side when omitted. */ - role?: ParamRole -} - export interface MetricDeclaration { name: string message_match_regex: string - params: MetricParamMatcher[] + /** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */ + label_params: string[] + /** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */ + value_param?: string | null /** Fixed labels applied on a match/non-match, on top of any captured params. */ matched_labels?: Record | null unmatched_labels?: Record | null