Make elapsed-since-last an explicit param extractor, not a Duration-kind special case (ticket 038)

Previously a Value-role param with kind: Duration silently meant "elapsed
time since the previous same-labeled event" instead of parsed text — the
same MetricPrimitiveKind::Duration variant meant two unrelated things
depending on which list (label_params vs value_param) referenced it.

Add ParamDeclaration::elapsed_since_last, a third extraction source
alongside capture_group/json_field_path: when set, the param is computed
(via a new resolve_param_value, used uniformly for label_params and
value_param) rather than text-extracted, and declare_param validates it's
paired with kind: Duration. apply_metric_to_log no longer branches on
(role, kind) — it dispatches purely on what each resolved param declares.

Migrate existing profiles: migrate_duration_value_params converts any
Duration-kind param referenced as a metric's value_param into the new
extractor, and resolve_or_create_legacy_param (ticket 037's pre-existing
migration) now carries the same meaning forward for pre-037 inline
Value-role Duration matchers.

Params.tsx offers "Elapsed since previous event" as its own Extraction
option (hides the now-irrelevant message-match-regex/capture-group
fields and forces kind to duration); Metrics.tsx labels such params
accordingly wherever they're listed as label/value param choices.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 23:01:32 +01:00
co-authored by Claude Sonnet 5
parent 375aa38ef8
commit 5e30e02a78
8 changed files with 352 additions and 105 deletions
+1
View File
@@ -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()
}
+16 -1
View File
@@ -22,6 +22,17 @@ pub struct ParamDeclaration {
#[serde(default)]
pub json_field_path: Option<String>,
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
+2
View File
@@ -23,6 +23,8 @@ pub struct ParamDeclarationDto {
/// See `crate::data_value::ParamDeclaration::json_field_path`.
pub json_field_path: Option<String>,
pub kind: MetricPrimitiveKind,
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`.
pub elapsed_since_last: bool,
}
#[derive(Debug, Clone, Serialize)]
+137 -65
View File
@@ -219,11 +219,19 @@ fn declare_param(
index: &mut BTreeMap<IndexKey, DataValue>,
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<Metric
.ok()
.map(MetricPrimitive::Timestamp)
}
// A Value-role param never reaches this branch (see apply_metric_to_log); reached only for a Label-role param or standalone ParamDeclaration, parsed as a literal millisecond count.
// An `elapsed_since_last` param never reaches this branch (see `resolve_param_value`, which
// computes its value directly rather than extracting/parsing text); reached only for a
// Duration-kind param that parses a literal captured/field millisecond count.
MetricPrimitiveKind::Duration => raw.parse::<i64>().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<String>,
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<IndexKey, DataValue>) -> HashMap<String, ResolvedParam> {
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<IndexKey, DataValue>) -> 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<IndexKey, DataValue>,
metric_name: &str,
resolved: &ResolvedParam,
labels_so_far: &BTreeMap<String, MetricPrimitive>,
text: &str,
timestamp: Timestamp,
) -> Option<MetricPrimitive> {
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<IndexKey, DataValue>,
@@ -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<IndexKey, DataValue>,
) -> impl Iterator<Item = (&str, &str, &str, Option<&str>, MetricPrimitiveKind)> {
) -> impl Iterator<Item = (&str, &str, &str, Option<&str>, 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<IndexKey, DataValue>,
) -> Vec<(String, Regex, String, Option<String>, 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<IndexKey, DataValue>, 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<IndexKey, DataValue>) {
let pattern = r"user=(?P<user>\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();
}
+128 -1
View File
@@ -69,6 +69,11 @@ pub struct ParamDeclarationRecord {
#[serde(default)]
pub json_field_path: Option<String>,
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<user>\\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<user>\\S+) logged in", "capture_group": "user", "kind": "string" },
{ "name": "gap", "message_match_regex": "user=(?P<user>\\S+) logged in", "capture_group": "user", "kind": "duration" }
],
"metrics": [
{
"name": "login_gap",
"message_match_regex": "user=(?P<user>\\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 `<metric>.<param>` declaration.
+3 -3
View File
@@ -254,7 +254,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
onChange={() => toggleLabelParam(p.name)}
/>
<span className="font-mono">{p.name}</span>
<span className="text-text-faint">({p.kind})</span>
<span className="text-text-faint">({p.elapsed_since_last ? 'elapsed since previous' : p.kind})</span>
</label>
))
)}
@@ -263,7 +263,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
<div>
<label
className="mb-1 block text-xs text-text-subtle"
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. A 'duration'-kind value param is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
title="At most one param becomes the counter's per-event captured value (used by histogram()/gauge()) instead of a label. An 'elapsed since previous event' param (declared in the Params panel) is computed automatically — elapsed time since the previous match with the same labels — rather than parsed from its capture group."
>
Value param
</label>
@@ -275,7 +275,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
<option value="">(none — occurrence count)</option>
{params.map((p) => (
<option key={p.name} value={p.name}>
{p.name} ({p.kind})
{p.name} ({p.elapsed_since_last ? 'elapsed since previous' : p.kind})
</option>
))}
</select>
+63 -35
View File
@@ -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
/>
</div>
<div>
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
Message match regex
<RegexHelpIcon label="Help: Message match regex syntax" />
</label>
<input
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={regex}
onChange={(e) => setRegex(e.target.value)}
placeholder={String.raw`request path=(?P<path>\S+)`}
required
/>
</div>
{detectedJson && !isBoolean && (
{!isElapsedSinceLast && (
<div>
<label className="mb-1 block text-xs text-text-subtle">Extraction</label>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={source}
onChange={(e) => setSource(e.target.value as ExtractionSource)}
>
<option value="regex">Regex capture group</option>
<option value="json_field">JSON field</option>
</select>
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
Message match regex
<RegexHelpIcon label="Help: Message match regex syntax" />
</label>
<input
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-sm text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
value={regex}
onChange={(e) => setRegex(e.target.value)}
placeholder={String.raw`request path=(?P<path>\S+)`}
required
/>
</div>
)}
{effectiveSource === 'json_field' ? (
<div>
<label className="mb-1 block text-xs text-text-subtle">Extraction</label>
<select
className="w-full rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
value={source}
onChange={(e) => handleSourceChange(e.target.value as ExtractionSource)}
>
<option value="regex">Regex capture group</option>
{detectedJson && !isBoolean && <option value="json_field">JSON field</option>}
<option value="elapsed_since_last">Elapsed since previous event</option>
</select>
</div>
{isElapsedSinceLast ? (
<p className="text-xs text-text-faint">
Computed automatically — elapsed time since the previous event that shares the same
labels wherever this param is used, not parsed from the log line.
</p>
) : effectiveSource === 'json_field' ? (
<div className="flex items-end gap-1">
<div className="min-w-0 flex-1">
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
@@ -199,12 +225,14 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
<span
key={p.name}
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
title={p.message_match_regex}
title={p.elapsed_since_last ? undefined : p.message_match_regex}
>
{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}`}`}
)
</span>
))}
+2
View File
@@ -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 {