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:
co-authored by
Claude Sonnet 5
parent
375aa38ef8
commit
5e30e02a78
+137
-65
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user