diff --git a/src/api.rs b/src/api.rs index a39e58f..0ca5719 100644 --- a/src/api.rs +++ b/src/api.rs @@ -244,6 +244,7 @@ async fn declare_param( capture_group: request.capture_group, json_field_path: request.json_field_path, kind: request.kind, + elapsed_since_last: request.elapsed_since_last, }; (StatusCode::CREATED, Json(dto)).into_response() } diff --git a/src/data_value.rs b/src/data_value.rs index 18b44a6..1d88ba7 100644 --- a/src/data_value.rs +++ b/src/data_value.rs @@ -22,6 +22,17 @@ pub struct ParamDeclaration { #[serde(default)] pub json_field_path: Option, pub kind: MetricPrimitiveKind, + /// A third extraction source, alongside `capture_group`/`json_field_path`: when set, this + /// param's value is never parsed from text at all — it's computed as elapsed time since the + /// previous event that produced the same label set (ticket 038). Takes precedence over + /// `json_field_path`/`capture_group` when set, and `message_match_regex`/`capture_group`/ + /// `json_field_path` are simply unused in that case (`declare_param` doesn't validate them for + /// this shape). Only meaningful paired with `kind == Duration`, since elapsed time is the only + /// primitive this computation can produce — `declare_param` enforces that pairing. + /// `#[serde(default)]` so params declared before this field existed keep parsing as plain + /// regex/JSON-field extraction. + #[serde(default)] + pub elapsed_since_last: bool, } /// Reads a value out of a JSON object by dot-separated field path (e.g. `"foo.bar"`), for @@ -56,7 +67,11 @@ pub enum MetricPrimitiveKind { String, Number, Timestamp, - /// 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. + /// A literal i64 millisecond count. Note this is unrelated to + /// `ParamDeclaration::elapsed_since_last`, which is a separate, independent extraction source — + /// a param using that source always parses/produces `Duration`, but not every `Duration`-kind + /// param is computed that way (one can also just parse a literal duration out of captured text, + /// like any other kind). 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 diff --git a/src/dto.rs b/src/dto.rs index 050d3a6..6dec5d1 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -23,6 +23,8 @@ pub struct ParamDeclarationDto { /// See `crate::data_value::ParamDeclaration::json_field_path`. pub json_field_path: Option, pub kind: MetricPrimitiveKind, + /// See `crate::data_value::ParamDeclaration::elapsed_since_last`. + pub elapsed_since_last: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/src/engine.rs b/src/engine.rs index f30eac6..b1bb33e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -219,11 +219,19 @@ fn declare_param( index: &mut BTreeMap, request: DeclareParamRequest, ) -> Result<(), String> { + // An `elapsed_since_last` param is never text-extracted at all, so `message_match_regex` is + // irrelevant to it — but the field always compiles as a regex regardless of extractor (an empty + // pattern is valid and matches trivially), so it's harmless to still require it to parse. let re = Regex::new(&request.message_match_regex) .map_err(|err| format!("invalid regex: {err}"))?; + if request.elapsed_since_last && request.kind != MetricPrimitiveKind::Duration { + return Err("elapsed_since_last params must declare kind 'duration'".to_string()); + } // A Boolean-kind param never uses a capture group — its value is just whether the regex matched. + // Neither does an `elapsed_since_last` param — its value is computed, not captured. let uses_json_field = request.json_field_path.as_deref().is_some_and(|path| !path.is_empty()); if request.kind != MetricPrimitiveKind::Boolean + && !request.elapsed_since_last && !uses_json_field && !re.capture_names().flatten().any(|name| name == request.capture_group) { @@ -371,7 +379,9 @@ fn parse_metric_primitive(raw: &str, kind: MetricPrimitiveKind) -> Option raw.parse::().ok().map(MetricPrimitive::Duration), // A standalone Boolean param never reaches this branch (see derive_log_params, which derives its // value from whether the regex matched rather than parsing captured text); reached only if a metric @@ -389,11 +399,15 @@ struct ResolvedParam { capture_group: String, json_field_path: Option, kind: MetricPrimitiveKind, + /// See `crate::data_value::ParamDeclaration::elapsed_since_last`. When set, `regex`/ + /// `capture_group`/`json_field_path` above are unused — resolution goes through + /// `resolve_param_value`'s computed path instead of `extract_param_raw`. + elapsed_since_last: bool, } fn resolved_param_declarations(index: &BTreeMap) -> HashMap { param_declarations_range(index) - .filter_map(|(name, message_match_regex, capture_group, json_field_path, kind)| { + .filter_map(|(name, message_match_regex, capture_group, json_field_path, kind, elapsed_since_last)| { Regex::new(message_match_regex).ok().map(|regex| { ( name.to_string(), @@ -402,6 +416,7 @@ fn resolved_param_declarations(index: &BTreeMap) -> HashMap capture_group: capture_group.to_string(), json_field_path: json_field_path.map(|path| path.to_string()), kind, + elapsed_since_last, }, ) }) @@ -426,14 +441,50 @@ fn extract_param_raw( } } +/// Resolves one param's value against `text`/`timestamp`, dispatching purely on what the param +/// itself declares (`ResolvedParam::elapsed_since_last`) rather than on whether it's being resolved +/// as a label or as the value — the same resolution is used for both in `apply_metric_to_log`, so +/// an `elapsed_since_last` param behaves identically regardless of which list references it. An +/// elapsed-since-last param's value is elapsed time since the previous event that produced +/// `labels_so_far` under `metric_name`, tracked via a `LastMetricEvent` index entry; its first +/// occurrence returns `None` (nothing to diff against yet) rather than a bogus zero. Every other +/// param falls through to ordinary text extraction (regex capture group or JSON field path) parsed +/// as its declared kind. +fn resolve_param_value( + index: &mut BTreeMap, + metric_name: &str, + resolved: &ResolvedParam, + labels_so_far: &BTreeMap, + text: &str, + timestamp: Timestamp, +) -> Option { + if resolved.elapsed_since_last { + // Tracked separately from Bucket: the first occurrence for a given label set pushes no + // Bucket entry, so the last-event timestamp can't live in Bucket.values. + let last_event_key = IndexKey::LastMetricEvent { + metric_name: metric_name.to_string(), + labels: labels_so_far.clone(), + }; + let previous_timestamp = match index.get(&last_event_key) { + Some(DataValue::LastEventTimestamp { timestamp: previous }) => Some(*previous), + _ => None, + }; + index.insert(last_event_key, DataValue::LastEventTimestamp { timestamp }); + return previous_timestamp + .map(|previous| MetricPrimitive::Duration(timestamp.as_millis() - previous.as_millis())); + } + + let raw = extract_param_raw(text, &resolved.regex, &resolved.capture_group, resolved.json_field_path.as_deref())?; + parse_metric_primitive(&raw, resolved.kind) +} + /// 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. +/// match rather than storing a partial one. 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, @@ -462,65 +513,27 @@ fn apply_metric_to_log( let mut labels = BTreeMap::new(); 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; - }; - let Some(parsed) = parse_metric_primitive(&raw, resolved.kind) else { + let Some(parsed) = resolve_param_value(index, metric_name, resolved, &labels, text, timestamp) 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())); } } - let value = if is_duration { - // Tracked separately from Bucket: a Duration's first occurrence pushes no Bucket entry, so the last-event timestamp can't live in Bucket.values. - let last_event_key = - IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() }; - let previous_timestamp = match index.get(&last_event_key) { - Some(DataValue::LastEventTimestamp { timestamp: previous }) => Some(*previous), - _ => None, - }; - index.insert(last_event_key, DataValue::LastEventTimestamp { timestamp }); - match previous_timestamp { - Some(previous) => { - MetricPrimitive::Duration(timestamp.as_millis() - previous.as_millis()) + let value = match value_param { + Some(value_param) => { + let Some(resolved) = resolved_params.get(value_param) else { return }; + match resolve_param_value(index, metric_name, resolved, &labels, text, timestamp) { + Some(parsed) => parsed, + None => return, } - None => return, } - } else { - value.unwrap_or(MetricPrimitive::Number(Number::Int(1))) + None => MetricPrimitive::Number(Number::Int(1)), }; push_bucket_value(index, metric_name, labels, sequence, timestamp, value); @@ -943,7 +956,7 @@ fn metric_declarations_range( fn param_declarations_range( index: &BTreeMap, -) -> impl Iterator, MetricPrimitiveKind)> { +) -> impl Iterator, MetricPrimitiveKind, bool)> { index .range(IndexKey::LogParamSplit..IndexKey::ParamMetricSplit) .filter_map(|(key, value)| match (key, value) { @@ -954,6 +967,7 @@ fn param_declarations_range( capture_group, json_field_path, kind, + elapsed_since_last, .. }), ) => Some(( @@ -962,17 +976,23 @@ fn param_declarations_range( capture_group.as_str(), json_field_path.as_deref(), *kind, + *elapsed_since_last, )), _ => None, }) } -/// A param whose stored regex fails to compile (shouldn't happen — `declare_param` validates it) is skipped rather than panicking. +/// A param whose stored regex fails to compile (shouldn't happen — `declare_param` validates it) is +/// skipped rather than panicking. An `elapsed_since_last` param is skipped outright — it has no +/// meaningful reading as a standalone logs-table column (its value depends on which metric's label +/// set it's grouped under, which only exists once it's referenced by a metric's `label_params`/ +/// `value_param`). fn compiled_param_declarations( index: &BTreeMap, ) -> Vec<(String, Regex, String, Option, MetricPrimitiveKind)> { param_declarations_range(index) - .filter_map(|(name, message_match_regex, capture_group, json_field_path, kind)| { + .filter(|(.., elapsed_since_last)| !elapsed_since_last) + .filter_map(|(name, message_match_regex, capture_group, json_field_path, kind, _)| { Regex::new(message_match_regex).ok().map(|re| { ( name.to_string(), @@ -1178,12 +1198,15 @@ fn handle_query( } EngineQuery::ParamDeclarations => { let declarations = param_declarations_range(index) - .map(|(name, message_match_regex, capture_group, json_field_path, kind)| ParamDeclarationDto { - name: name.to_string(), - message_match_regex: message_match_regex.to_string(), - capture_group: capture_group.to_string(), - json_field_path: json_field_path.map(|path| path.to_string()), - kind, + .map(|(name, message_match_regex, capture_group, json_field_path, kind, elapsed_since_last)| { + ParamDeclarationDto { + name: name.to_string(), + message_match_regex: message_match_regex.to_string(), + capture_group: capture_group.to_string(), + json_field_path: json_field_path.map(|path| path.to_string()), + kind, + elapsed_since_last, + } }) .collect(); EngineResponse::ParamDeclarations(declarations) @@ -1554,6 +1577,7 @@ mod tests { capture_group: capture_group.to_string(), json_field_path: None, kind: MetricPrimitiveKind::String, + elapsed_since_last: false, } } @@ -1589,6 +1613,34 @@ mod tests { assert!(index.is_empty()); } + #[test] + fn declares_valid_elapsed_since_last_param_without_capture_group() { + let mut index = BTreeMap::new(); + declare_elapsed_since_last_param(&mut index, "gap"); + + let declarations: Vec<_> = param_declarations_range(&index).collect(); + assert_eq!(declarations.len(), 1); + assert!(declarations[0].5, "expected elapsed_since_last to be true"); + } + + #[test] + fn rejects_elapsed_since_last_param_with_non_duration_kind() { + let mut index = BTreeMap::new(); + let result = declare_param( + &mut index, + DeclareParamRequest { + name: "gap".to_string(), + message_match_regex: ".".to_string(), + capture_group: String::new(), + json_field_path: None, + kind: MetricPrimitiveKind::String, + elapsed_since_last: true, + }, + ); + assert!(result.is_err()); + assert!(index.is_empty()); + } + #[test] fn declares_valid_param_from_json_field_path() { let mut index = BTreeMap::new(); @@ -1602,6 +1654,7 @@ mod tests { capture_group: String::new(), json_field_path: Some("foo.bar".to_string()), kind: MetricPrimitiveKind::String, + elapsed_since_last: false, }, ); assert!(result.is_ok()); @@ -1622,6 +1675,7 @@ mod tests { capture_group: String::new(), json_field_path: Some("foo.bar".to_string()), kind: MetricPrimitiveKind::Number, + elapsed_since_last: false, }, ) .unwrap(); @@ -1737,6 +1791,26 @@ mod tests { capture_group: capture_group.to_string(), json_field_path: None, kind, + elapsed_since_last: false, + }, + ) + .unwrap(); + } + + /// Declares a standalone `elapsed_since_last` param (ticket 038) — computed as elapsed time + /// since the previous event sharing whatever label set it's grouped under, never text-extracted. + /// `message_match_regex`/`capture_group` are unused for this extractor but still need to satisfy + /// `declare_param`'s baseline "is this a valid regex" check, so an always-matching `"."` is fine. + fn declare_elapsed_since_last_param(index: &mut BTreeMap, name: &str) { + declare_param( + index, + DeclareParamRequest { + name: name.to_string(), + message_match_regex: ".".to_string(), + capture_group: String::new(), + json_field_path: None, + kind: MetricPrimitiveKind::Duration, + elapsed_since_last: true, }, ) .unwrap(); @@ -1957,6 +2031,7 @@ mod tests { capture_group: String::new(), json_field_path: Some("request.path".to_string()), kind: MetricPrimitiveKind::String, + elapsed_since_last: false, }, ) .unwrap(); @@ -2579,10 +2654,7 @@ mod tests { fn declare_login_duration_metric(index: &mut BTreeMap) { 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_elapsed_since_last_param(index, "gap"); declare_metric(index, metric_request_with_value("login_gap", pattern, vec!["user"], Some("gap"))).unwrap(); } diff --git a/src/profile.rs b/src/profile.rs index e52bb2a..6e7b3cc 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -69,6 +69,11 @@ pub struct ParamDeclarationRecord { #[serde(default)] pub json_field_path: Option, pub kind: ProfileMetricPrimitiveKind, + /// See `crate::data_value::ParamDeclaration::elapsed_since_last`; `#[serde(default)]` so + /// profiles saved before ticket 038 (where a `Duration`-kind value param implicitly meant this) + /// still load — `migrate_duration_value_params` below converts those. + #[serde(default)] + pub elapsed_since_last: bool, } impl From<&DeclareParamRequest> for ParamDeclarationRecord { @@ -79,6 +84,7 @@ impl From<&DeclareParamRequest> for ParamDeclarationRecord { capture_group: request.capture_group.clone(), json_field_path: request.json_field_path.clone(), kind: request.kind.into(), + elapsed_since_last: request.elapsed_since_last, } } } @@ -91,6 +97,7 @@ impl From<&ParamDeclarationRecord> for DeclareParamRequest { capture_group: record.capture_group.clone(), json_field_path: record.json_field_path.clone(), kind: record.kind.into(), + elapsed_since_last: record.elapsed_since_last, } } } @@ -236,11 +243,44 @@ fn resolve_or_create_legacy_param( capture_group: matcher.capture_group.clone(), json_field_path: matcher.json_field_path.clone(), kind: matcher.kind, + // Pre-037, a Value-role Duration matcher implicitly meant "elapsed time since the + // previous same-labeled event" (ticket 038's now-explicit `elapsed_since_last`) — carry + // that meaning forward rather than dropping it into a meaningless literal-parse matcher. + elapsed_since_last: matcher.role == LegacyParamRole::Value + && matcher.kind == ProfileMetricPrimitiveKind::Duration, }); } implicit_name } +/// Migrates every already-declared `Duration`-kind param that's referenced as some metric's +/// `value_param` into the explicit `elapsed_since_last` extractor (ticket 038). Before ticket 038, a +/// `Duration`-kind value param implicitly meant "elapsed time since the previous same-labeled +/// event" (see `crate::engine::apply_metric_to_log`'s pre-038 history) — this migration makes that +/// meaning explicit so `Duration` kind no longer has a role-dependent special case. Only params +/// actually referenced as *some* metric's value param are converted: a `Duration`-kind param used +/// purely as a label param already meant a literal parsed millisecond count, and keeps meaning that. +/// Runs after `migrate_legacy_metric_params` (which populates `value_param` from pre-037 +/// `legacy_params` and already sets `elapsed_since_last` correctly for those via +/// `resolve_or_create_legacy_param`), so this only has new work to do for profiles saved between +/// ticket 037 and ticket 038 landing, where `value_param` already pointed at a bare `Duration`-kind +/// param straight from the unified shape. Returns whether anything changed. +fn migrate_duration_value_params(profile: &mut Profile) -> bool { + let value_param_names: std::collections::HashSet<&str> = + profile.metrics.iter().filter_map(|metric| metric.value_param.as_deref()).collect(); + let mut migrated = false; + for param in &mut profile.params { + if !param.elapsed_since_last + && param.kind == ProfileMetricPrimitiveKind::Duration + && value_param_names.contains(param.name.as_str()) + { + param.elapsed_since_last = true; + migrated = true; + } + } + migrated +} + /// A chart type a dashboard panel can render; kept in sync with the frontend's `'bar' | 'line' | 'pie'` union (see `webapp/src/lib/api.ts`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -485,9 +525,17 @@ impl ProfileStore { // load; persisting the result isn't required for correctness (migration re-runs // identically next time), but it's done here anyway so the on-disk file only carries the // legacy `"params"` shape once, on the very next save. + // + // Ticket 038: follows on immediately after — once `migrate_legacy_metric_params` has + // resolved every metric's `value_param`, convert any `Duration`-kind param still referenced + // that way into the explicit `elapsed_since_last` extractor, so a profile saved either before + // 037 (inline matchers) or between 037 and 038 (bare `value_param` reference) ends up + // behaving identically under the new, explicit shape. let mut any_migrated = false; for profile in &mut store.profiles { - if migrate_legacy_metric_params(profile) { + let legacy_migrated = migrate_legacy_metric_params(profile); + let duration_migrated = migrate_duration_value_params(profile); + if legacy_migrated || duration_migrated { any_migrated = true; } } @@ -766,6 +814,7 @@ mod tests { capture_group: capture_group.to_string(), json_field_path: None, kind: MetricPrimitiveKind::String, + elapsed_since_last: false, } } @@ -1283,6 +1332,84 @@ mod tests { }); } + /// Acceptance criterion (ticket 038): a pre-037 legacy profile whose inline `Value`-role matcher + /// used `kind: "duration"` (the old "elapsed time since previous event" special case) migrates + /// into a standalone param with the new explicit `elapsed_since_last: true` extractor, not just a + /// bare `Duration`-kind param that would now (post-038) mean a literal parsed millisecond count. + #[test] + fn migrates_legacy_value_role_duration_matcher_into_elapsed_since_last_param() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("profiles.json"); + write_legacy_profiles_json( + &store_path, + r#"[ + { + "name": "legacy", + "params": [], + "metrics": [ + { + "name": "login_gap", + "message_match_regex": "user=(?P\\S+) logged in", + "params": [ + { "param_name": "user", "capture_group": "user", "kind": "string", "role": "label" }, + { "param_name": "gap", "capture_group": "user", "kind": "duration", "role": "value" } + ] + } + ], + "dashboards": [] + } + ]"#, + ); + + let store = ProfileStore::load(&store_path); + let profile = store.get("legacy").unwrap(); + + let gap_param = profile.params.iter().find(|p| p.name == "login_gap.gap").unwrap(); + assert!(matches!(gap_param.kind, ProfileMetricPrimitiveKind::Duration)); + assert!(gap_param.elapsed_since_last); + assert_eq!(profile.metrics[0].value_param, Some("login_gap.gap".to_string())); + } + + /// Acceptance criterion (ticket 038): a profile saved after ticket 037 landed but before ticket + /// 038 — so it already carries the unified `label_params`/`value_param` shape rather than legacy + /// inline matchers, with a bare `Duration`-kind param referenced as a metric's `value_param` — is + /// migrated the same way: the param gains `elapsed_since_last: true` so its behavior is unchanged + /// (elapsed time since the previous same-labeled event) under the new explicit extractor. + #[test] + fn migrates_post_037_bare_duration_value_param_into_elapsed_since_last() { + let dir = tempfile::tempdir().unwrap(); + let store_path = dir.path().join("profiles.json"); + write_legacy_profiles_json( + &store_path, + r#"[ + { + "name": "legacy", + "params": [ + { "name": "user", "message_match_regex": "user=(?P\\S+) logged in", "capture_group": "user", "kind": "string" }, + { "name": "gap", "message_match_regex": "user=(?P\\S+) logged in", "capture_group": "user", "kind": "duration" } + ], + "metrics": [ + { + "name": "login_gap", + "message_match_regex": "user=(?P\\S+) logged in", + "label_params": ["user"], + "value_param": "gap" + } + ], + "dashboards": [] + } + ]"#, + ); + + let store = ProfileStore::load(&store_path); + let profile = store.get("legacy").unwrap(); + + let gap_param = profile.params.iter().find(|p| p.name == "gap").unwrap(); + assert!(gap_param.elapsed_since_last); + // A Duration-kind param not referenced as any value_param is left untouched (still a literal parse). + assert!(!profile.params.iter().find(|p| p.name == "user").unwrap().elapsed_since_last); + } + /// When a bare-named param already declared on the profile matches a legacy matcher's shape /// exactly (same regex/capture-group/kind), migration reuses it by name instead of creating a /// redundant `.` declaration. diff --git a/webapp/src/components/Metrics.tsx b/webapp/src/components/Metrics.tsx index 0573d34..05dd41c 100644 --- a/webapp/src/components/Metrics.tsx +++ b/webapp/src/components/Metrics.tsx @@ -254,7 +254,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti onChange={() => toggleLabelParam(p.name)} /> {p.name} - ({p.kind}) + ({p.elapsed_since_last ? 'elapsed since previous' : p.kind}) )) )} @@ -263,7 +263,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
@@ -275,7 +275,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti {params.map((p) => ( ))} diff --git a/webapp/src/components/Params.tsx b/webapp/src/components/Params.tsx index d9c1b29..5ffcce1 100644 --- a/webapp/src/components/Params.tsx +++ b/webapp/src/components/Params.tsx @@ -13,7 +13,11 @@ import { RegexHelpIcon } from './RegexHelpIcon' const EMPTY_KIND: ParamDeclaration['kind'] = 'string' -type ExtractionSource = 'regex' | 'json_field' +// 'elapsed_since_last' is always offered, regardless of what the message match regex looks like — +// unlike 'json_field' (which only makes sense once the sample line looks like a JSON object), this +// param's value is never extracted from text at all: it's elapsed time since the previous event +// sharing the same label set (ticket 038). +type ExtractionSource = 'regex' | 'json_field' | 'elapsed_since_last' /// A JSON *object* (not an array/scalar) parses cleanly and pretty-prints with braces — the same /// shape a JSON field path can be read from, so this is the "line looks like JSON" gate that @@ -41,23 +45,39 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD const [collapsed, setCollapsed] = useSectionCollapsed('params') const isBoolean = kind === 'boolean' + const isElapsedSinceLast = source === 'elapsed_since_last' // The message match regex doubles as a sample line here so a pasted JSON log line unlocks the - // "JSON field" extraction option — regex-based params are unaffected either way. + // "JSON field" extraction option — regex-based params are unaffected either way. Not consulted at + // all for "elapsed since previous event", which is always offered. const detectedJson = useMemo(() => looksLikeJsonObject(regex), [regex]) - const effectiveSource: ExtractionSource = detectedJson && !isBoolean ? source : 'regex' + const effectiveSource: ExtractionSource = isElapsedSinceLast + ? 'elapsed_since_last' + : detectedJson && !isBoolean + ? source + : 'regex' + + function handleSourceChange(next: ExtractionSource) { + setSource(next) + // "Elapsed since previous event" always produces a Duration value — never text-extracted — so + // there's no separate kind to pick; force it rather than leaving a stale/mismatched kind selected. + if (next === 'elapsed_since_last') setKind('duration') + } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setSubmitError(null) setSubmitting(true) try { - // A boolean-kind param never uses a capture group — its value is just whether the regex matched. + // A boolean-kind param never uses a capture group — its value is just whether the regex + // matched. An elapsed-since-last param never uses a capture group, JSON field path, or even + // its own message match regex — its value is computed, not extracted from text at all. await declareParam({ name, - message_match_regex: regex, - capture_group: isBoolean || effectiveSource === 'json_field' ? '' : captureGroup, + message_match_regex: effectiveSource === 'elapsed_since_last' ? '' : regex, + capture_group: isBoolean || effectiveSource !== 'regex' ? '' : captureGroup, json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null, - kind, + kind: effectiveSource === 'elapsed_since_last' ? 'duration' : kind, + elapsed_since_last: effectiveSource === 'elapsed_since_last', }) setName('') setRegex('') @@ -104,33 +124,39 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD required />
-
- - setRegex(e.target.value)} - placeholder={String.raw`request path=(?P\S+)`} - required - /> -
- {detectedJson && !isBoolean && ( + {!isElapsedSinceLast && (
- - + + setRegex(e.target.value)} + placeholder={String.raw`request path=(?P\S+)`} + required + />
)} - {effectiveSource === 'json_field' ? ( +
+ + +
+ {isElapsedSinceLast ? ( +

+ Computed automatically — elapsed time since the previous event that shares the same + labels wherever this param is used, not parsed from the log line. +

+ ) : effectiveSource === 'json_field' ? (
@@ -199,12 +225,14 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD {p.name} ({p.kind} - {p.kind === 'boolean' - ? '' - : `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`} + {p.elapsed_since_last + ? ', elapsed since previous' + : p.kind === 'boolean' + ? '' + : `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`} ) ))} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 2fb49d5..ed1255d 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -30,6 +30,8 @@ export interface ParamDeclaration { /** Alternative to `capture_group`: a dot-separated JSON field path (e.g. "foo.bar"), read from lines that parse as a JSON object. When set (non-empty), takes precedence over `capture_group`. */ json_field_path?: string | null kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean' + /** A third extraction source, distinct from `capture_group`/`json_field_path`: when true, this param's value is never parsed from text — it's computed as elapsed time since the previous event sharing the same label set. Only meaningful paired with `kind: 'duration'`. */ + elapsed_since_last?: boolean } export interface TimelineBucket {