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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+132
-60
@@ -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,
|
||||
}
|
||||
} 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 {
|
||||
.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
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +124,7 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{!isElapsedSinceLast && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
|
||||
Message match regex
|
||||
@@ -117,20 +138,25 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{detectedJson && !isBoolean && (
|
||||
)}
|
||||
<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)}
|
||||
onChange={(e) => handleSourceChange(e.target.value as ExtractionSource)}
|
||||
>
|
||||
<option value="regex">Regex capture group</option>
|
||||
<option value="json_field">JSON field</option>
|
||||
{detectedJson && !isBoolean && <option value="json_field">JSON field</option>}
|
||||
<option value="elapsed_since_last">Elapsed since previous event</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{effectiveSource === 'json_field' ? (
|
||||
{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,10 +225,12 @@ 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.elapsed_since_last
|
||||
? ', elapsed since previous'
|
||||
: p.kind === 'boolean'
|
||||
? ''
|
||||
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user