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,
|
capture_group: request.capture_group,
|
||||||
json_field_path: request.json_field_path,
|
json_field_path: request.json_field_path,
|
||||||
kind: request.kind,
|
kind: request.kind,
|
||||||
|
elapsed_since_last: request.elapsed_since_last,
|
||||||
};
|
};
|
||||||
(StatusCode::CREATED, Json(dto)).into_response()
|
(StatusCode::CREATED, Json(dto)).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -22,6 +22,17 @@ pub struct ParamDeclaration {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: MetricPrimitiveKind,
|
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
|
/// 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,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
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,
|
Duration,
|
||||||
/// A standalone param declared with this kind doesn't require (or use) a capture group: its value is
|
/// 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
|
/// 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`.
|
/// See `crate::data_value::ParamDeclaration::json_field_path`.
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
|
/// See `crate::data_value::ParamDeclaration::elapsed_since_last`.
|
||||||
|
pub elapsed_since_last: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
+137
-65
@@ -219,11 +219,19 @@ fn declare_param(
|
|||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
request: DeclareParamRequest,
|
request: DeclareParamRequest,
|
||||||
) -> Result<(), String> {
|
) -> 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)
|
let re = Regex::new(&request.message_match_regex)
|
||||||
.map_err(|err| format!("invalid regex: {err}"))?;
|
.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.
|
// 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());
|
let uses_json_field = request.json_field_path.as_deref().is_some_and(|path| !path.is_empty());
|
||||||
if request.kind != MetricPrimitiveKind::Boolean
|
if request.kind != MetricPrimitiveKind::Boolean
|
||||||
|
&& !request.elapsed_since_last
|
||||||
&& !uses_json_field
|
&& !uses_json_field
|
||||||
&& !re.capture_names().flatten().any(|name| name == request.capture_group)
|
&& !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()
|
.ok()
|
||||||
.map(MetricPrimitive::Timestamp)
|
.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),
|
MetricPrimitiveKind::Duration => raw.parse::<i64>().ok().map(MetricPrimitive::Duration),
|
||||||
// A standalone Boolean param never reaches this branch (see derive_log_params, which derives its
|
// 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
|
// 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,
|
capture_group: String,
|
||||||
json_field_path: Option<String>,
|
json_field_path: Option<String>,
|
||||||
kind: MetricPrimitiveKind,
|
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> {
|
fn resolved_param_declarations(index: &BTreeMap<IndexKey, DataValue>) -> HashMap<String, ResolvedParam> {
|
||||||
param_declarations_range(index)
|
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| {
|
Regex::new(message_match_regex).ok().map(|regex| {
|
||||||
(
|
(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
@@ -402,6 +416,7 @@ fn resolved_param_declarations(index: &BTreeMap<IndexKey, DataValue>) -> HashMap
|
|||||||
capture_group: capture_group.to_string(),
|
capture_group: capture_group.to_string(),
|
||||||
json_field_path: json_field_path.map(|path| path.to_string()),
|
json_field_path: json_field_path.map(|path| path.to_string()),
|
||||||
kind,
|
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
|
/// 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
|
/// (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
|
/// 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
|
/// 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
|
/// match rather than storing a partial one. When `regex` (the metric's own message-match regex)
|
||||||
/// previous same-labeled event, so the first occurrence pushes no bucket (nothing to diff against)
|
/// doesn't match and `unmatched_labels` is `Some`, an occurrence is still registered with those
|
||||||
/// rather than a bogus zero. When `regex` (the metric's own message-match regex) doesn't match and
|
/// fixed labels.
|
||||||
/// `unmatched_labels` is `Some`, an occurrence is still registered with those fixed labels.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn apply_metric_to_log(
|
fn apply_metric_to_log(
|
||||||
index: &mut BTreeMap<IndexKey, DataValue>,
|
index: &mut BTreeMap<IndexKey, DataValue>,
|
||||||
@@ -462,65 +513,27 @@ fn apply_metric_to_log(
|
|||||||
let mut labels = BTreeMap::new();
|
let mut labels = BTreeMap::new();
|
||||||
for param_name in label_params {
|
for param_name in label_params {
|
||||||
let Some(resolved) = resolved_params.get(param_name) else { return };
|
let Some(resolved) = resolved_params.get(param_name) else { return };
|
||||||
let Some(raw) = extract_param_raw(
|
let Some(parsed) = resolve_param_value(index, metric_name, resolved, &labels, text, timestamp) else {
|
||||||
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;
|
return;
|
||||||
};
|
};
|
||||||
labels.insert(param_name.clone(), parsed);
|
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 {
|
if let Some(matched_labels) = matched_labels {
|
||||||
for (name, value) in matched_labels {
|
for (name, value) in matched_labels {
|
||||||
labels.insert(name.clone(), MetricPrimitive::String(value.clone()));
|
labels.insert(name.clone(), MetricPrimitive::String(value.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let value = if is_duration {
|
let value = match value_param {
|
||||||
// Tracked separately from Bucket: a Duration's first occurrence pushes no Bucket entry, so the last-event timestamp can't live in Bucket.values.
|
Some(value_param) => {
|
||||||
let last_event_key =
|
let Some(resolved) = resolved_params.get(value_param) else { return };
|
||||||
IndexKey::LastMetricEvent { metric_name: metric_name.to_string(), labels: labels.clone() };
|
match resolve_param_value(index, metric_name, resolved, &labels, text, timestamp) {
|
||||||
let previous_timestamp = match index.get(&last_event_key) {
|
Some(parsed) => parsed,
|
||||||
Some(DataValue::LastEventTimestamp { timestamp: previous }) => Some(*previous),
|
None => return,
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
index.insert(last_event_key, DataValue::LastEventTimestamp { timestamp });
|
|
||||||
match previous_timestamp {
|
|
||||||
Some(previous) => {
|
|
||||||
MetricPrimitive::Duration(timestamp.as_millis() - previous.as_millis())
|
|
||||||
}
|
}
|
||||||
None => return,
|
|
||||||
}
|
}
|
||||||
} else {
|
None => MetricPrimitive::Number(Number::Int(1)),
|
||||||
value.unwrap_or(MetricPrimitive::Number(Number::Int(1)))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
push_bucket_value(index, metric_name, labels, sequence, timestamp, value);
|
push_bucket_value(index, metric_name, labels, sequence, timestamp, value);
|
||||||
@@ -943,7 +956,7 @@ fn metric_declarations_range(
|
|||||||
|
|
||||||
fn param_declarations_range(
|
fn param_declarations_range(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
) -> impl Iterator<Item = (&str, &str, &str, Option<&str>, MetricPrimitiveKind)> {
|
) -> impl Iterator<Item = (&str, &str, &str, Option<&str>, MetricPrimitiveKind, bool)> {
|
||||||
index
|
index
|
||||||
.range(IndexKey::LogParamSplit..IndexKey::ParamMetricSplit)
|
.range(IndexKey::LogParamSplit..IndexKey::ParamMetricSplit)
|
||||||
.filter_map(|(key, value)| match (key, value) {
|
.filter_map(|(key, value)| match (key, value) {
|
||||||
@@ -954,6 +967,7 @@ fn param_declarations_range(
|
|||||||
capture_group,
|
capture_group,
|
||||||
json_field_path,
|
json_field_path,
|
||||||
kind,
|
kind,
|
||||||
|
elapsed_since_last,
|
||||||
..
|
..
|
||||||
}),
|
}),
|
||||||
) => Some((
|
) => Some((
|
||||||
@@ -962,17 +976,23 @@ fn param_declarations_range(
|
|||||||
capture_group.as_str(),
|
capture_group.as_str(),
|
||||||
json_field_path.as_deref(),
|
json_field_path.as_deref(),
|
||||||
*kind,
|
*kind,
|
||||||
|
*elapsed_since_last,
|
||||||
)),
|
)),
|
||||||
_ => None,
|
_ => 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(
|
fn compiled_param_declarations(
|
||||||
index: &BTreeMap<IndexKey, DataValue>,
|
index: &BTreeMap<IndexKey, DataValue>,
|
||||||
) -> Vec<(String, Regex, String, Option<String>, MetricPrimitiveKind)> {
|
) -> Vec<(String, Regex, String, Option<String>, MetricPrimitiveKind)> {
|
||||||
param_declarations_range(index)
|
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| {
|
Regex::new(message_match_regex).ok().map(|re| {
|
||||||
(
|
(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
@@ -1178,12 +1198,15 @@ fn handle_query(
|
|||||||
}
|
}
|
||||||
EngineQuery::ParamDeclarations => {
|
EngineQuery::ParamDeclarations => {
|
||||||
let declarations = param_declarations_range(index)
|
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)| {
|
||||||
name: name.to_string(),
|
ParamDeclarationDto {
|
||||||
message_match_regex: message_match_regex.to_string(),
|
name: name.to_string(),
|
||||||
capture_group: capture_group.to_string(),
|
message_match_regex: message_match_regex.to_string(),
|
||||||
json_field_path: json_field_path.map(|path| path.to_string()),
|
capture_group: capture_group.to_string(),
|
||||||
kind,
|
json_field_path: json_field_path.map(|path| path.to_string()),
|
||||||
|
kind,
|
||||||
|
elapsed_since_last,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
EngineResponse::ParamDeclarations(declarations)
|
EngineResponse::ParamDeclarations(declarations)
|
||||||
@@ -1554,6 +1577,7 @@ mod tests {
|
|||||||
capture_group: capture_group.to_string(),
|
capture_group: capture_group.to_string(),
|
||||||
json_field_path: None,
|
json_field_path: None,
|
||||||
kind: MetricPrimitiveKind::String,
|
kind: MetricPrimitiveKind::String,
|
||||||
|
elapsed_since_last: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,6 +1613,34 @@ mod tests {
|
|||||||
assert!(index.is_empty());
|
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]
|
#[test]
|
||||||
fn declares_valid_param_from_json_field_path() {
|
fn declares_valid_param_from_json_field_path() {
|
||||||
let mut index = BTreeMap::new();
|
let mut index = BTreeMap::new();
|
||||||
@@ -1602,6 +1654,7 @@ mod tests {
|
|||||||
capture_group: String::new(),
|
capture_group: String::new(),
|
||||||
json_field_path: Some("foo.bar".to_string()),
|
json_field_path: Some("foo.bar".to_string()),
|
||||||
kind: MetricPrimitiveKind::String,
|
kind: MetricPrimitiveKind::String,
|
||||||
|
elapsed_since_last: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -1622,6 +1675,7 @@ mod tests {
|
|||||||
capture_group: String::new(),
|
capture_group: String::new(),
|
||||||
json_field_path: Some("foo.bar".to_string()),
|
json_field_path: Some("foo.bar".to_string()),
|
||||||
kind: MetricPrimitiveKind::Number,
|
kind: MetricPrimitiveKind::Number,
|
||||||
|
elapsed_since_last: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1737,6 +1791,26 @@ mod tests {
|
|||||||
capture_group: capture_group.to_string(),
|
capture_group: capture_group.to_string(),
|
||||||
json_field_path: None,
|
json_field_path: None,
|
||||||
kind,
|
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();
|
.unwrap();
|
||||||
@@ -1957,6 +2031,7 @@ mod tests {
|
|||||||
capture_group: String::new(),
|
capture_group: String::new(),
|
||||||
json_field_path: Some("request.path".to_string()),
|
json_field_path: Some("request.path".to_string()),
|
||||||
kind: MetricPrimitiveKind::String,
|
kind: MetricPrimitiveKind::String,
|
||||||
|
elapsed_since_last: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2579,10 +2654,7 @@ mod tests {
|
|||||||
fn declare_login_duration_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
fn declare_login_duration_metric(index: &mut BTreeMap<IndexKey, DataValue>) {
|
||||||
let pattern = r"user=(?P<user>\S+) logged in";
|
let pattern = r"user=(?P<user>\S+) logged in";
|
||||||
declare_test_param(index, "user", pattern, "user", MetricPrimitiveKind::String);
|
declare_test_param(index, "user", pattern, "user", MetricPrimitiveKind::String);
|
||||||
// A Duration-kind value param never actually extracts via its own capture group (see
|
declare_elapsed_since_last_param(index, "gap");
|
||||||
// `apply_metric_to_log`) — its regex/capture-group only need to satisfy `declare_param`'s
|
|
||||||
// validation, so reusing "user" here is harmless.
|
|
||||||
declare_test_param(index, "gap", pattern, "user", MetricPrimitiveKind::Duration);
|
|
||||||
declare_metric(index, metric_request_with_value("login_gap", pattern, vec!["user"], Some("gap"))).unwrap();
|
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)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: ProfileMetricPrimitiveKind,
|
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 {
|
impl From<&DeclareParamRequest> for ParamDeclarationRecord {
|
||||||
@@ -79,6 +84,7 @@ impl From<&DeclareParamRequest> for ParamDeclarationRecord {
|
|||||||
capture_group: request.capture_group.clone(),
|
capture_group: request.capture_group.clone(),
|
||||||
json_field_path: request.json_field_path.clone(),
|
json_field_path: request.json_field_path.clone(),
|
||||||
kind: request.kind.into(),
|
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(),
|
capture_group: record.capture_group.clone(),
|
||||||
json_field_path: record.json_field_path.clone(),
|
json_field_path: record.json_field_path.clone(),
|
||||||
kind: record.kind.into(),
|
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(),
|
capture_group: matcher.capture_group.clone(),
|
||||||
json_field_path: matcher.json_field_path.clone(),
|
json_field_path: matcher.json_field_path.clone(),
|
||||||
kind: matcher.kind,
|
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
|
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`).
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -485,9 +525,17 @@ impl ProfileStore {
|
|||||||
// load; persisting the result isn't required for correctness (migration re-runs
|
// 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
|
// 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.
|
// 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;
|
let mut any_migrated = false;
|
||||||
for profile in &mut store.profiles {
|
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;
|
any_migrated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -766,6 +814,7 @@ mod tests {
|
|||||||
capture_group: capture_group.to_string(),
|
capture_group: capture_group.to_string(),
|
||||||
json_field_path: None,
|
json_field_path: None,
|
||||||
kind: MetricPrimitiveKind::String,
|
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
|
/// 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
|
/// exactly (same regex/capture-group/kind), migration reuses it by name instead of creating a
|
||||||
/// redundant `<metric>.<param>` declaration.
|
/// redundant `<metric>.<param>` declaration.
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
onChange={() => toggleLabelParam(p.name)}
|
onChange={() => toggleLabelParam(p.name)}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono">{p.name}</span>
|
<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>
|
</label>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -263,7 +263,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="mb-1 block text-xs text-text-subtle"
|
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
|
Value param
|
||||||
</label>
|
</label>
|
||||||
@@ -275,7 +275,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<option value="">(none — occurrence count)</option>
|
<option value="">(none — occurrence count)</option>
|
||||||
{params.map((p) => (
|
{params.map((p) => (
|
||||||
<option key={p.name} value={p.name}>
|
<option key={p.name} value={p.name}>
|
||||||
{p.name} ({p.kind})
|
{p.name} ({p.elapsed_since_last ? 'elapsed since previous' : p.kind})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { RegexHelpIcon } from './RegexHelpIcon'
|
|||||||
|
|
||||||
const EMPTY_KIND: ParamDeclaration['kind'] = 'string'
|
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
|
/// 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
|
/// 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 [collapsed, setCollapsed] = useSectionCollapsed('params')
|
||||||
|
|
||||||
const isBoolean = kind === 'boolean'
|
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
|
// 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 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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
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({
|
await declareParam({
|
||||||
name,
|
name,
|
||||||
message_match_regex: regex,
|
message_match_regex: effectiveSource === 'elapsed_since_last' ? '' : regex,
|
||||||
capture_group: isBoolean || effectiveSource === 'json_field' ? '' : captureGroup,
|
capture_group: isBoolean || effectiveSource !== 'regex' ? '' : captureGroup,
|
||||||
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
json_field_path: effectiveSource === 'json_field' ? jsonFieldPath : null,
|
||||||
kind,
|
kind: effectiveSource === 'elapsed_since_last' ? 'duration' : kind,
|
||||||
|
elapsed_since_last: effectiveSource === 'elapsed_since_last',
|
||||||
})
|
})
|
||||||
setName('')
|
setName('')
|
||||||
setRegex('')
|
setRegex('')
|
||||||
@@ -104,33 +124,39 @@ export function Params({ params, onDeclared }: { params: ParamDeclaration[]; onD
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{!isElapsedSinceLast && (
|
||||||
<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 && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-subtle">Extraction</label>
|
<label className="mb-1 flex items-center gap-1 text-xs text-text-subtle">
|
||||||
<select
|
Message match regex
|
||||||
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"
|
<RegexHelpIcon label="Help: Message match regex syntax" />
|
||||||
value={source}
|
</label>
|
||||||
onChange={(e) => setSource(e.target.value as ExtractionSource)}
|
<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"
|
||||||
<option value="regex">Regex capture group</option>
|
value={regex}
|
||||||
<option value="json_field">JSON field</option>
|
onChange={(e) => setRegex(e.target.value)}
|
||||||
</select>
|
placeholder={String.raw`request path=(?P<path>\S+)`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</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="flex items-end gap-1">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label className="mb-1 block text-xs text-text-subtle">JSON field path</label>
|
<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
|
<span
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text-secondary"
|
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.name} ({p.kind}
|
||||||
{p.kind === 'boolean'
|
{p.elapsed_since_last
|
||||||
? ''
|
? ', elapsed since previous'
|
||||||
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
: p.kind === 'boolean'
|
||||||
|
? ''
|
||||||
|
: `, ${p.json_field_path ? `json field ${p.json_field_path}` : `group ${p.capture_group}`}`}
|
||||||
)
|
)
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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`. */
|
/** 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
|
json_field_path?: string | null
|
||||||
kind: 'string' | 'number' | 'timestamp' | 'duration' | 'boolean'
|
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 {
|
export interface TimelineBucket {
|
||||||
|
|||||||
Reference in New Issue
Block a user