Unify param declarations: metrics reference params by name (ticket 037)
Collapse the Params-panel ParamDeclaration and the metric-embedded RegexToMetricPrimitiveMatcher into a single ParamDeclaration type (name, regex/capture-group-or-json-field-path, kind). MetricDeclaration now references params by name via label_params: Vec<String> and value_param: Option<String> instead of embedding its own extractor per param, so a param declared once in the Params panel can be reused as a label or value by any number of metrics. Declaring a metric that references an undeclared param name is now rejected at declare-time. Backend: - src/data_value.rs: unified ParamDeclaration struct; MetricDeclaration data value now carries label_params/value_param instead of a Vec of inline matchers. - src/engine.rs: DeclareParamRequest is now an alias for ParamDeclaration; DeclareMetricRequest carries label_params/ value_param; validate_metric_request checks referenced param names against the declared-param store; apply_metric_to_log resolves label_params/value_param against a per-ingest resolved-param map instead of reading an inline matcher list. - src/profile.rs: MetricDeclarationRecord carries label_params/ value_param plus a private, serialize-skipped legacy_params field that only ever gets populated by deserializing a pre-037 profile file's old "params" key. ProfileStore::load migrates any such legacy metric into the unified shape (reusing an existing same-shaped bare param by name, or creating one implicitly named "<metric_name>.<param_name>"), then persists the migrated store so the on-disk file only carries the legacy shape once. Migration is idempotent (deterministic naming) and covered by dedicated tests. - src/api.rs / src/dto.rs: MetricDeclarationDto and the declare/update metric response bodies updated to the new shape. Frontend: - webapp/src/lib/api.ts: MetricDeclaration type now has label_params/ value_param; MetricParamMatcher/ParamRole types removed. - webapp/src/components/Metrics.tsx: the declaration form now fetches declared params and lets you check off label params / pick a value param from that list, instead of re-entering a regex/capture-group/ kind per metric param. Leaves ticket 038 (duration as a derived param kind) and 039 (param query resolving declared params) untouched, as scoped. 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
85fb145be7
commit
e397f9fbdb
+40
-29
@@ -280,7 +280,8 @@ async fn declare_metric(
|
|||||||
let dto = MetricDeclarationDto {
|
let dto = MetricDeclarationDto {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
message_match_regex: request.message_match_regex,
|
message_match_regex: request.message_match_regex,
|
||||||
params: request.params,
|
label_params: request.label_params,
|
||||||
|
value_param: request.value_param,
|
||||||
matched_labels: request.matched_labels,
|
matched_labels: request.matched_labels,
|
||||||
unmatched_labels: request.unmatched_labels,
|
unmatched_labels: request.unmatched_labels,
|
||||||
};
|
};
|
||||||
@@ -313,7 +314,8 @@ async fn update_metric(
|
|||||||
let dto = MetricDeclarationDto {
|
let dto = MetricDeclarationDto {
|
||||||
name: request.name,
|
name: request.name,
|
||||||
message_match_regex: request.message_match_regex,
|
message_match_regex: request.message_match_regex,
|
||||||
params: request.params,
|
label_params: request.label_params,
|
||||||
|
value_param: request.value_param,
|
||||||
matched_labels: request.matched_labels,
|
matched_labels: request.matched_labels,
|
||||||
unmatched_labels: request.unmatched_labels,
|
unmatched_labels: request.unmatched_labels,
|
||||||
};
|
};
|
||||||
@@ -906,9 +908,29 @@ mod tests {
|
|||||||
assert!(body["error"].as_str().unwrap().contains("invalid regex"));
|
assert!(body["error"].as_str().unwrap().contains("invalid regex"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Declares a standalone param via `POST /api/params` — most metric-declaration tests need one
|
||||||
|
/// or more already-declared params to reference by name (ticket 037), rather than embedding a
|
||||||
|
/// regex/capture-group/kind inline in the metric request.
|
||||||
|
async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) {
|
||||||
|
let (status, _body) = send_json(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/params",
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"message_match_regex": pattern,
|
||||||
|
"capture_group": capture_group,
|
||||||
|
"kind": kind,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn declares_metric_and_shows_up_in_list() {
|
async fn declares_metric_and_shows_up_in_list() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
let (status, body) = send_json(
|
let (status, body) = send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -916,9 +938,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -933,7 +953,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_metric_with_unknown_capture_group() {
|
async fn rejects_metric_referencing_undeclared_param() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
let (status, body) = send_json(
|
let (status, body) = send_json(
|
||||||
&app,
|
&app,
|
||||||
@@ -942,9 +962,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["duration"]
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -955,6 +973,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
|
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
|
||||||
let (app, _engine, _settings, _dir) = test_app();
|
let (app, _engine, _settings, _dir) = test_app();
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
|
declare_test_param(&app, "duration", r"duration=(?P<duration>\d+)", "duration", "number").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -962,9 +982,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -976,23 +994,20 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
||||||
"params": [
|
"label_params": ["path", "duration"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" },
|
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(body["name"], "requests");
|
assert_eq!(body["name"], "requests");
|
||||||
assert_eq!(body["params"].as_array().unwrap().len(), 2);
|
assert_eq!(body["label_params"].as_array().unwrap().len(), 2);
|
||||||
|
|
||||||
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
|
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
|
||||||
let (status, body) = get_json(&app, "/api/metrics").await;
|
let (status, body) = get_json(&app, "/api/metrics").await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
let metrics = body.as_array().unwrap();
|
let metrics = body.as_array().unwrap();
|
||||||
assert_eq!(metrics.len(), 1);
|
assert_eq!(metrics.len(), 1);
|
||||||
assert_eq!(metrics[0]["params"].as_array().unwrap().len(), 2);
|
assert_eq!(metrics[0]["label_params"].as_array().unwrap().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1005,7 +1020,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "does-not-exist",
|
"name": "does-not-exist",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": []
|
"label_params": []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1023,7 +1038,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": []
|
"label_params": []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1035,9 +1050,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["duration"]
|
||||||
{ "param_name": "duration", "capture_group": "duration", "kind": "number" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1049,6 +1062,7 @@ mod tests {
|
|||||||
async fn queries_bucket_values_with_label_filter_and_range() {
|
async fn queries_bucket_values_with_label_filter_and_range() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
|
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1056,9 +1070,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1103,6 +1115,7 @@ mod tests {
|
|||||||
async fn promql_query_returns_series_for_seeded_fixture() {
|
async fn promql_query_returns_series_for_seeded_fixture() {
|
||||||
let (app, engine, _settings, _dir) = test_app();
|
let (app, engine, _settings, _dir) = test_app();
|
||||||
|
|
||||||
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
||||||
send_json(
|
send_json(
|
||||||
&app,
|
&app,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1110,9 +1123,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"name": "requests",
|
"name": "requests",
|
||||||
"message_match_regex": r"request path=(?P<path>\S+)",
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
||||||
"params": [
|
"label_params": ["path"]
|
||||||
{ "param_name": "path", "capture_group": "path", "kind": "string" }
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+22
-31
@@ -3,29 +3,31 @@ use crate::timestamp::Timestamp;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// A named, standalone parameter: how to pull a value out of a matching log line, and what kind it
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// parses as. Ticket 037 unified this with the metric-declaration-embedded matcher that used to
|
||||||
pub struct RegexToMetricPrimitiveMatcher {
|
/// exist alongside it (`RegexToMetricPrimitiveMatcher`) — a metric now references params by name
|
||||||
pub param_name: String,
|
/// (`MetricDeclaration::label_params`/`value_param`) instead of redeclaring its own extractor per
|
||||||
|
/// param, so a param declared once (e.g. via the Params panel) can be reused as a label by one
|
||||||
|
/// metric and as the value by another without re-entering its regex/capture-group/kind.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ParamDeclaration {
|
||||||
|
pub name: String,
|
||||||
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
|
/// Alternative extraction source to `capture_group`: a dot-separated JSON field path (e.g.
|
||||||
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
|
/// `foo.bar`), read from lines that parse as a JSON object instead of via the regex capture
|
||||||
/// group. When present and non-empty, this takes precedence over `capture_group`.
|
/// group. When present and non-empty, this takes precedence over `capture_group`.
|
||||||
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
|
/// `#[serde(default)]` so profile files/requests saved before this field existed still load
|
||||||
/// as plain regex-capture-group matchers.
|
/// as plain regex-capture-group params.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
pub kind: MetricPrimitiveKind,
|
pub kind: MetricPrimitiveKind,
|
||||||
/// Whether this param is a bucket label or its captured value (at most one Value-role param per metric); `#[serde(default)]` so profile files saved before this field existed still load as `Label`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub role: ParamRole,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
/// [`RegexToMetricPrimitiveMatcher::json_field_path`] and the sibling `json_field_path` on a
|
/// [`ParamDeclaration::json_field_path`]. Returns `None` when `text` doesn't parse as a JSON
|
||||||
/// standalone param declaration. Returns `None` when `text` doesn't parse as a JSON object, the
|
/// object, the path doesn't resolve to a value, or the resolved value isn't a scalar
|
||||||
/// path doesn't resolve to a value, or the resolved value isn't a scalar (string/number/bool) —
|
/// (string/number/bool) — there's no sensible flat text form of a nested object/array to feed into
|
||||||
/// there's no sensible flat text form of a nested object/array to feed into
|
|
||||||
/// `parse_metric_primitive`.
|
/// `parse_metric_primitive`.
|
||||||
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
||||||
let root: serde_json::Value = serde_json::from_str(text).ok()?;
|
let root: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||||
@@ -46,15 +48,6 @@ pub fn extract_json_field(text: &str, field_path: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See [`RegexToMetricPrimitiveMatcher::role`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ParamRole {
|
|
||||||
#[default]
|
|
||||||
Label,
|
|
||||||
Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
|
/// `rename_all = "lowercase"` matches other `kind`-style enums, so JSON bodies use "string"/"number"/"timestamp".
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -63,7 +56,7 @@ pub enum MetricPrimitiveKind {
|
|||||||
String,
|
String,
|
||||||
Number,
|
Number,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
/// On a Value-role param this is derived as elapsed time since the previous event (not parsed from text); otherwise it's a literal i64 millisecond count.
|
/// 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.
|
||||||
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
|
||||||
@@ -79,17 +72,15 @@ pub enum DataValue {
|
|||||||
/// Paired with `IndexKey::IngestedLog`.
|
/// Paired with `IndexKey::IngestedLog`.
|
||||||
LogLiteral { text: String },
|
LogLiteral { text: String },
|
||||||
/// Paired with `IndexKey::ParamDeclaration`.
|
/// Paired with `IndexKey::ParamDeclaration`.
|
||||||
ParamDeclaration {
|
ParamDeclaration(ParamDeclaration),
|
||||||
message_match_regex: String,
|
/// Paired with `IndexKey::MetricDeclaration`. `label_params`/`value_param` reference standalone
|
||||||
capture_group: String,
|
/// `ParamDeclaration`s by name (resolved against the engine's param store in
|
||||||
/// See `RegexToMetricPrimitiveMatcher::json_field_path`.
|
/// `crate::engine::apply_metric_to_log`) rather than embedding their own extractor.
|
||||||
json_field_path: Option<String>,
|
|
||||||
kind: MetricPrimitiveKind,
|
|
||||||
},
|
|
||||||
/// Paired with `IndexKey::MetricDeclaration`.
|
|
||||||
MetricDeclaration {
|
MetricDeclaration {
|
||||||
message_match_regex: String,
|
message_match_regex: String,
|
||||||
params: Vec<RegexToMetricPrimitiveMatcher>,
|
label_params: Vec<String>,
|
||||||
|
/// At most one value param per metric; its primitive becomes the event's bucket value instead of a label.
|
||||||
|
value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
||||||
matched_labels: Option<BTreeMap<String, String>>,
|
matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
|
|||||||
+6
-3
@@ -1,5 +1,5 @@
|
|||||||
use crate::alerts::AlertCondition;
|
use crate::alerts::AlertCondition;
|
||||||
use crate::data_value::{MetricPrimitiveKind, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::MetricPrimitiveKind;
|
||||||
use crate::metric_primitive::MetricPrimitive;
|
use crate::metric_primitive::MetricPrimitive;
|
||||||
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
use crate::profile::{ChartType, Dashboard, Panel, Profile};
|
||||||
use crate::settings::{ColorScheme, CssEntry, Settings};
|
use crate::settings::{ColorScheme, CssEntry, Settings};
|
||||||
@@ -20,7 +20,7 @@ pub struct ParamDeclarationDto {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// See `crate::data_value::RegexToMetricPrimitiveMatcher::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,
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,10 @@ pub struct ParamDeclarationDto {
|
|||||||
pub struct MetricDeclarationDto {
|
pub struct MetricDeclarationDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<RegexToMetricPrimitiveMatcher>,
|
/// Names of standalone `ParamDeclaration`s used as this metric's bucket labels.
|
||||||
|
pub label_params: Vec<String>,
|
||||||
|
/// Name of the standalone `ParamDeclaration` (if any) whose primitive becomes this metric's per-event bucket value instead of a label.
|
||||||
|
pub value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`.
|
||||||
pub matched_labels: Option<BTreeMap<String, String>>,
|
pub matched_labels: Option<BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`.
|
||||||
|
|||||||
+304
-371
File diff suppressed because it is too large
Load Diff
+304
-69
@@ -13,7 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::data_value::{MetricPrimitiveKind, ParamRole, RegexToMetricPrimitiveMatcher};
|
use crate::data_value::MetricPrimitiveKind;
|
||||||
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
|
use crate::engine::{DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
@@ -58,13 +58,13 @@ impl From<ProfileMetricPrimitiveKind> for MetricPrimitiveKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored param declaration, matching `DeclareParamRequest`'s shape.
|
/// A stored param declaration, matching `DeclareParamRequest`'s shape (== `data_value::ParamDeclaration`).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ParamDeclarationRecord {
|
pub struct ParamDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub capture_group: String,
|
pub capture_group: String,
|
||||||
/// See `crate::data_value::RegexToMetricPrimitiveMatcher::json_field_path`; `#[serde(default)]`
|
/// See `crate::data_value::ParamDeclaration::json_field_path`; `#[serde(default)]`
|
||||||
/// so profiles saved before this field existed still load as plain regex-capture-group params.
|
/// so profiles saved before this field existed still load as plain regex-capture-group params.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub json_field_path: Option<String>,
|
pub json_field_path: Option<String>,
|
||||||
@@ -95,33 +95,56 @@ impl From<&ParamDeclarationRecord> for DeclareParamRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored metric param matcher, matching `RegexToMetricPrimitiveMatcher`'s shape.
|
/// Pre-037 role of an inline metric param matcher — a label identifying a bucket, or the (at most
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
/// one) value that becomes the event's captured value. Kept only so `LegacyMetricParamRecord` can
|
||||||
pub struct MetricParamRecord {
|
/// still deserialize a profile saved before params were unified into standalone `ParamDeclaration`s
|
||||||
pub param_name: String,
|
/// referenced by name; see `migrate_legacy_metric_params`.
|
||||||
pub capture_group: String,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
/// See `crate::data_value::RegexToMetricPrimitiveMatcher::json_field_path`; `#[serde(default)]`
|
#[serde(rename_all = "lowercase")]
|
||||||
/// so profiles saved before this field existed still load as plain regex-capture-group matchers.
|
enum LegacyParamRole {
|
||||||
#[serde(default)]
|
#[default]
|
||||||
pub json_field_path: Option<String>,
|
Label,
|
||||||
pub kind: ProfileMetricPrimitiveKind,
|
Value,
|
||||||
/// See `ParamRole`; `#[serde(default)]` so profiles saved before this field existed still load, defaulting to `Label`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub role: ParamRole,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape.
|
/// Pre-037 inline metric param matcher shape (formerly `data_value::RegexToMetricPrimitiveMatcher`),
|
||||||
|
/// kept only to support migrating profiles saved before ticket 037 unified params — see
|
||||||
|
/// `migrate_legacy_metric_params`. Never produced when serializing a record built from a
|
||||||
|
/// `DeclareMetricRequest`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct LegacyMetricParamRecord {
|
||||||
|
param_name: String,
|
||||||
|
capture_group: String,
|
||||||
|
#[serde(default)]
|
||||||
|
json_field_path: Option<String>,
|
||||||
|
kind: ProfileMetricPrimitiveKind,
|
||||||
|
#[serde(default)]
|
||||||
|
role: LegacyParamRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored metric declaration, matching `DeclareMetricRequest`'s shape. `legacy_params` is only
|
||||||
|
/// ever populated by deserializing a pre-037 profile file (old key name `"params"`) — see
|
||||||
|
/// `migrate_legacy_metric_params`, which converts it into `label_params`/`value_param` plus any
|
||||||
|
/// implicitly-created standalone params, and empties it back out so a subsequent save never writes
|
||||||
|
/// the old shape again.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MetricDeclarationRecord {
|
pub struct MetricDeclarationRecord {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub message_match_regex: String,
|
pub message_match_regex: String,
|
||||||
pub params: Vec<MetricParamRecord>,
|
/// Names of standalone `ParamDeclarationRecord`s used as this metric's bucket labels.
|
||||||
|
#[serde(default)]
|
||||||
|
pub label_params: Vec<String>,
|
||||||
|
/// Name of the standalone `ParamDeclarationRecord` (if any) whose value becomes this metric's bucket value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub value_param: Option<String>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
|
/// See `crate::engine::DeclareMetricRequest::matched_labels`; `#[serde(default)]` so older profiles still load.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub matched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
|
/// See `crate::engine::DeclareMetricRequest::unmatched_labels`; `#[serde(default)]` so older profiles still load.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
pub unmatched_labels: Option<std::collections::BTreeMap<String, String>>,
|
||||||
|
#[serde(default, rename = "params", skip_serializing_if = "Vec::is_empty")]
|
||||||
|
legacy_params: Vec<LegacyMetricParamRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&DeclareMetricRequest> for MetricDeclarationRecord {
|
impl From<&DeclareMetricRequest> for MetricDeclarationRecord {
|
||||||
@@ -129,19 +152,11 @@ impl From<&DeclareMetricRequest> for MetricDeclarationRecord {
|
|||||||
MetricDeclarationRecord {
|
MetricDeclarationRecord {
|
||||||
name: request.name.clone(),
|
name: request.name.clone(),
|
||||||
message_match_regex: request.message_match_regex.clone(),
|
message_match_regex: request.message_match_regex.clone(),
|
||||||
params: request
|
label_params: request.label_params.clone(),
|
||||||
.params
|
value_param: request.value_param.clone(),
|
||||||
.iter()
|
|
||||||
.map(|matcher| MetricParamRecord {
|
|
||||||
param_name: matcher.param_name.clone(),
|
|
||||||
capture_group: matcher.capture_group.clone(),
|
|
||||||
json_field_path: matcher.json_field_path.clone(),
|
|
||||||
kind: matcher.kind.into(),
|
|
||||||
role: matcher.role,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
matched_labels: request.matched_labels.clone(),
|
matched_labels: request.matched_labels.clone(),
|
||||||
unmatched_labels: request.unmatched_labels.clone(),
|
unmatched_labels: request.unmatched_labels.clone(),
|
||||||
|
legacy_params: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,23 +166,81 @@ impl From<&MetricDeclarationRecord> for DeclareMetricRequest {
|
|||||||
DeclareMetricRequest {
|
DeclareMetricRequest {
|
||||||
name: record.name.clone(),
|
name: record.name.clone(),
|
||||||
message_match_regex: record.message_match_regex.clone(),
|
message_match_regex: record.message_match_regex.clone(),
|
||||||
|
label_params: record.label_params.clone(),
|
||||||
|
value_param: record.value_param.clone(),
|
||||||
matched_labels: record.matched_labels.clone(),
|
matched_labels: record.matched_labels.clone(),
|
||||||
unmatched_labels: record.unmatched_labels.clone(),
|
unmatched_labels: record.unmatched_labels.clone(),
|
||||||
params: record
|
|
||||||
.params
|
|
||||||
.iter()
|
|
||||||
.map(|param| RegexToMetricPrimitiveMatcher {
|
|
||||||
param_name: param.param_name.clone(),
|
|
||||||
capture_group: param.capture_group.clone(),
|
|
||||||
json_field_path: param.json_field_path.clone(),
|
|
||||||
kind: param.kind.into(),
|
|
||||||
role: param.role,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrates every metric in `profile` still carrying pre-037 inline matchers
|
||||||
|
/// (`MetricDeclarationRecord::legacy_params`, deserialized from an old profile file's `"params"`
|
||||||
|
/// key) into the unified shape: each matcher becomes a reference to a standalone
|
||||||
|
/// `ParamDeclarationRecord`, resolved by reusing an existing declared param of the exact same name
|
||||||
|
/// and shape (regex/capture-group-or-json-field-path/kind) when one exists, or else creating a new
|
||||||
|
/// one implicitly named `<metric_name>.<param_name>` (added to `profile.params`). Deterministic
|
||||||
|
/// naming means re-running this on an already-migrated profile is a no-op, and running it twice on
|
||||||
|
/// the same legacy data produces the same result. Returns whether anything changed, so the caller
|
||||||
|
/// can decide whether to persist the migration.
|
||||||
|
fn migrate_legacy_metric_params(profile: &mut Profile) -> bool {
|
||||||
|
let mut migrated = false;
|
||||||
|
for metric_index in 0..profile.metrics.len() {
|
||||||
|
if profile.metrics[metric_index].legacy_params.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metric_name = profile.metrics[metric_index].name.clone();
|
||||||
|
let message_match_regex = profile.metrics[metric_index].message_match_regex.clone();
|
||||||
|
let legacy = std::mem::take(&mut profile.metrics[metric_index].legacy_params);
|
||||||
|
|
||||||
|
let mut label_params = Vec::new();
|
||||||
|
let mut value_param = None;
|
||||||
|
for matcher in &legacy {
|
||||||
|
let param_name =
|
||||||
|
resolve_or_create_legacy_param(profile, &metric_name, &message_match_regex, matcher);
|
||||||
|
match matcher.role {
|
||||||
|
LegacyParamRole::Label => label_params.push(param_name),
|
||||||
|
LegacyParamRole::Value => value_param = Some(param_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profile.metrics[metric_index].label_params = label_params;
|
||||||
|
profile.metrics[metric_index].value_param = value_param;
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one legacy matcher to a standalone param name, reusing an existing declared param of
|
||||||
|
/// the same bare name and shape, or else creating `<metric_name>.<param_name>` on `profile.params`.
|
||||||
|
fn resolve_or_create_legacy_param(
|
||||||
|
profile: &mut Profile,
|
||||||
|
metric_name: &str,
|
||||||
|
message_match_regex: &str,
|
||||||
|
matcher: &LegacyMetricParamRecord,
|
||||||
|
) -> String {
|
||||||
|
if let Some(existing) = profile.params.iter().find(|p| p.name == matcher.param_name) {
|
||||||
|
if existing.message_match_regex == message_match_regex
|
||||||
|
&& existing.capture_group == matcher.capture_group
|
||||||
|
&& existing.json_field_path == matcher.json_field_path
|
||||||
|
&& existing.kind == matcher.kind
|
||||||
|
{
|
||||||
|
return existing.name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let implicit_name = format!("{metric_name}.{}", matcher.param_name);
|
||||||
|
if !profile.params.iter().any(|p| p.name == implicit_name) {
|
||||||
|
profile.params.push(ParamDeclarationRecord {
|
||||||
|
name: implicit_name.clone(),
|
||||||
|
message_match_regex: message_match_regex.to_string(),
|
||||||
|
capture_group: matcher.capture_group.clone(),
|
||||||
|
json_field_path: matcher.json_field_path.clone(),
|
||||||
|
kind: matcher.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
implicit_name
|
||||||
|
}
|
||||||
|
|
||||||
/// 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")]
|
||||||
@@ -223,9 +296,11 @@ fn seeded_error_split_metric() -> MetricDeclarationRecord {
|
|||||||
MetricDeclarationRecord {
|
MetricDeclarationRecord {
|
||||||
name: SEEDED_METRIC_NAME.to_string(),
|
name: SEEDED_METRIC_NAME.to_string(),
|
||||||
message_match_regex: r"(?i)error".to_string(),
|
message_match_regex: r"(?i)error".to_string(),
|
||||||
params: Vec::new(),
|
label_params: Vec::new(),
|
||||||
|
value_param: None,
|
||||||
matched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "error".to_string())])),
|
matched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "error".to_string())])),
|
||||||
unmatched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "other".to_string())])),
|
unmatched_labels: Some(BTreeMap::from([(SEEDED_METRIC_LABEL.to_string(), "other".to_string())])),
|
||||||
|
legacy_params: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +459,7 @@ pub struct ProfileStore {
|
|||||||
impl ProfileStore {
|
impl ProfileStore {
|
||||||
/// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
|
/// Loads the store from `store_path`; a missing or unreadable/unparsable file is treated as an empty store rather than failing startup.
|
||||||
pub fn load(store_path: &Path) -> ProfileStore {
|
pub fn load(store_path: &Path) -> ProfileStore {
|
||||||
match fs::read_to_string(store_path) {
|
let mut store = match fs::read_to_string(store_path) {
|
||||||
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
|
Ok(contents) => match serde_json::from_str::<Vec<Profile>>(&contents) {
|
||||||
Ok(profiles) => ProfileStore { profiles },
|
Ok(profiles) => ProfileStore { profiles },
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -403,7 +478,29 @@ impl ProfileStore {
|
|||||||
);
|
);
|
||||||
ProfileStore::default()
|
ProfileStore::default()
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ticket 037: turn any pre-unification inline metric matchers into standalone param
|
||||||
|
// references. Deterministic naming makes this idempotent, so it's safe to run on every
|
||||||
|
// 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.
|
||||||
|
let mut any_migrated = false;
|
||||||
|
for profile in &mut store.profiles {
|
||||||
|
if migrate_legacy_metric_params(profile) {
|
||||||
|
any_migrated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if any_migrated {
|
||||||
|
if let Err(err) = store.save(store_path) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to persist migrated profile store at {}: {err}",
|
||||||
|
store_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
|
/// Persists the store to `store_path` as pretty-printed JSON, creating any missing parent directories first.
|
||||||
@@ -672,19 +769,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metric_request(name: &str, pattern: &str, param_name: &str, capture_group: &str) -> DeclareMetricRequest {
|
fn metric_request(name: &str, pattern: &str, param_name: &str) -> DeclareMetricRequest {
|
||||||
DeclareMetricRequest {
|
DeclareMetricRequest {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
message_match_regex: pattern.to_string(),
|
message_match_regex: pattern.to_string(),
|
||||||
matched_labels: None,
|
matched_labels: None,
|
||||||
unmatched_labels: None,
|
unmatched_labels: None,
|
||||||
params: vec![RegexToMetricPrimitiveMatcher {
|
label_params: vec![param_name.to_string()],
|
||||||
param_name: param_name.to_string(),
|
value_param: None,
|
||||||
capture_group: capture_group.to_string(),
|
|
||||||
json_field_path: None,
|
|
||||||
kind: MetricPrimitiveKind::Number,
|
|
||||||
role: ParamRole::Label,
|
|
||||||
}],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,12 +1030,7 @@ mod tests {
|
|||||||
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
|
let param_req = param_request("level", r"level=(?P<level>\w+)", "level");
|
||||||
store.record_param_declaration(&store_path, "web", ¶m_req).unwrap();
|
store.record_param_declaration(&store_path, "web", ¶m_req).unwrap();
|
||||||
|
|
||||||
let metric_req = metric_request(
|
let metric_req = metric_request("requests", r"duration=(?P<duration>\d+)", "duration");
|
||||||
"requests",
|
|
||||||
r"duration=(?P<duration>\d+)",
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
);
|
|
||||||
store.record_metric_declaration(&store_path, "web", &metric_req).unwrap();
|
store.record_metric_declaration(&store_path, "web", &metric_req).unwrap();
|
||||||
|
|
||||||
let profile = store.get("web").unwrap();
|
let profile = store.get("web").unwrap();
|
||||||
@@ -984,16 +1071,20 @@ mod tests {
|
|||||||
¶m_request("level", r"level=(?P<level>\w+)", "level"),
|
¶m_request("level", r"level=(?P<level>\w+)", "level"),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// A metric now references params by name (ticket 037), so "duration" must be declared as a
|
||||||
|
// standalone param before the metric below can reference it as a label param.
|
||||||
|
store
|
||||||
|
.record_param_declaration(
|
||||||
|
&store_path,
|
||||||
|
"web",
|
||||||
|
¶m_request("duration", r"request duration=(?P<duration>\d+)", "duration"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
store
|
store
|
||||||
.record_metric_declaration(
|
.record_metric_declaration(
|
||||||
&store_path,
|
&store_path,
|
||||||
"web",
|
"web",
|
||||||
&metric_request(
|
&metric_request("requests", r"request duration=(?P<duration>\d+)", "duration"),
|
||||||
"requests",
|
|
||||||
r"request duration=(?P<duration>\d+)",
|
|
||||||
"duration",
|
|
||||||
"duration",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
|
store.set_active(&mut settings, &settings_path, Some("web".to_string())).unwrap();
|
||||||
@@ -1026,11 +1117,11 @@ mod tests {
|
|||||||
EngineResponse::ParamDeclarations(params) => params,
|
EngineResponse::ParamDeclarations(params) => params,
|
||||||
_ => panic!("unexpected response"),
|
_ => panic!("unexpected response"),
|
||||||
};
|
};
|
||||||
assert_eq!(params.len(), 1);
|
assert_eq!(params.len(), 2);
|
||||||
assert_eq!(params[0].name, "level");
|
let level = params.iter().find(|p| p.name == "level").unwrap();
|
||||||
assert_eq!(params[0].message_match_regex, r"level=(?P<level>\w+)");
|
assert_eq!(level.message_match_regex, r"level=(?P<level>\w+)");
|
||||||
assert_eq!(params[0].capture_group, "level");
|
assert_eq!(level.capture_group, "level");
|
||||||
assert!(matches!(params[0].kind, MetricPrimitiveKind::String));
|
assert!(matches!(level.kind, MetricPrimitiveKind::String));
|
||||||
|
|
||||||
let metrics = match ask(&engine_tx, EngineQuery::MetricDeclarations).await {
|
let metrics = match ask(&engine_tx, EngineQuery::MetricDeclarations).await {
|
||||||
EngineResponse::MetricDeclarations(metrics) => metrics,
|
EngineResponse::MetricDeclarations(metrics) => metrics,
|
||||||
@@ -1039,8 +1130,7 @@ mod tests {
|
|||||||
assert_eq!(metrics.len(), 1);
|
assert_eq!(metrics.len(), 1);
|
||||||
assert_eq!(metrics[0].name, "requests");
|
assert_eq!(metrics[0].name, "requests");
|
||||||
assert_eq!(metrics[0].message_match_regex, r"request duration=(?P<duration>\d+)");
|
assert_eq!(metrics[0].message_match_regex, r"request duration=(?P<duration>\d+)");
|
||||||
assert_eq!(metrics[0].params.len(), 1);
|
assert_eq!(metrics[0].label_params, vec!["duration".to_string()]);
|
||||||
assert_eq!(metrics[0].params[0].param_name, "duration");
|
|
||||||
|
|
||||||
let logs = match ask(
|
let logs = match ask(
|
||||||
&engine_tx,
|
&engine_tx,
|
||||||
@@ -1111,4 +1201,149 @@ mod tests {
|
|||||||
Err(ProfileError::NotFound)
|
Err(ProfileError::NotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes a pre-037 `profiles.json` (metrics carrying inline `"params"` matchers rather than
|
||||||
|
/// `label_params`/`value_param`) directly to disk, bypassing `ProfileStore`/`Profile` entirely
|
||||||
|
/// so this doesn't accidentally exercise the current (post-migration) shape.
|
||||||
|
fn write_legacy_profiles_json(store_path: &Path, contents: &str) {
|
||||||
|
fs::create_dir_all(store_path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(store_path, contents).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance criterion: a profile saved before ticket 037 (inline `param_name`/`capture_group`/
|
||||||
|
/// `kind`/`role` matchers embedded in the metric) loads with those matchers turned into
|
||||||
|
/// standalone param declarations referenced by name, and the metric still ingests identically.
|
||||||
|
#[test]
|
||||||
|
fn migrates_legacy_inline_metric_params_into_standalone_declarations() {
|
||||||
|
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": "requests",
|
||||||
|
"message_match_regex": "request path=(?P<path>\\S+) duration=(?P<duration>\\d+)",
|
||||||
|
"params": [
|
||||||
|
{ "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" },
|
||||||
|
{ "param_name": "duration", "capture_group": "duration", "kind": "number", "role": "value" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboards": []
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let store = ProfileStore::load(&store_path);
|
||||||
|
let profile = store.get("legacy").unwrap();
|
||||||
|
|
||||||
|
// No pre-existing bare "path"/"duration" param, so implicit ones were created, named after the metric.
|
||||||
|
assert_eq!(profile.params.len(), 2);
|
||||||
|
let path_param = profile.params.iter().find(|p| p.name == "requests.path").unwrap();
|
||||||
|
assert_eq!(path_param.message_match_regex, profile.metrics[0].message_match_regex);
|
||||||
|
assert_eq!(path_param.capture_group, "path");
|
||||||
|
assert!(matches!(path_param.kind, ProfileMetricPrimitiveKind::String));
|
||||||
|
let duration_param = profile.params.iter().find(|p| p.name == "requests.duration").unwrap();
|
||||||
|
assert!(matches!(duration_param.kind, ProfileMetricPrimitiveKind::Number));
|
||||||
|
|
||||||
|
assert_eq!(profile.metrics.len(), 1);
|
||||||
|
assert_eq!(profile.metrics[0].label_params, vec!["requests.path".to_string()]);
|
||||||
|
assert_eq!(profile.metrics[0].value_param, Some("requests.duration".to_string()));
|
||||||
|
|
||||||
|
// The migration is persisted, so the on-disk file no longer carries the legacy "params" shape.
|
||||||
|
let reloaded_raw = fs::read_to_string(&store_path).unwrap();
|
||||||
|
assert!(!reloaded_raw.contains("param_name"));
|
||||||
|
|
||||||
|
// Replaying into a fresh engine behaves identically to before migration: the metric still ingests and buckets.
|
||||||
|
let (engine_tx, engine_rx) = mpsc::channel(16);
|
||||||
|
let settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
||||||
|
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||||
|
tokio::spawn(crate::engine::run(engine_rx, settings_handle));
|
||||||
|
load_into_engine(&engine_tx, profile).await;
|
||||||
|
engine_tx
|
||||||
|
.send(EngineMessage::Ingest(IngestedLine {
|
||||||
|
sequence: 0,
|
||||||
|
timestamp: Timestamp::from_millis(0),
|
||||||
|
text: "request path=/a duration=42".to_string(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let EngineResponse::MetricDeclarations(metrics) =
|
||||||
|
ask(&engine_tx, EngineQuery::MetricDeclarations).await
|
||||||
|
else {
|
||||||
|
panic!("unexpected response");
|
||||||
|
};
|
||||||
|
assert_eq!(metrics.len(), 1);
|
||||||
|
assert_eq!(metrics[0].label_params, vec!["requests.path".to_string()]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[test]
|
||||||
|
fn migration_reuses_an_existing_matching_bare_named_param() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store_path = dir.path().join("profiles.json");
|
||||||
|
write_legacy_profiles_json(
|
||||||
|
&store_path,
|
||||||
|
r#"[
|
||||||
|
{
|
||||||
|
"name": "legacy",
|
||||||
|
"params": [
|
||||||
|
{ "name": "path", "message_match_regex": "request path=(?P<path>\\S+)", "capture_group": "path", "kind": "string" }
|
||||||
|
],
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"name": "requests",
|
||||||
|
"message_match_regex": "request path=(?P<path>\\S+)",
|
||||||
|
"params": [
|
||||||
|
{ "param_name": "path", "capture_group": "path", "kind": "string", "role": "label" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dashboards": []
|
||||||
|
}
|
||||||
|
]"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let store = ProfileStore::load(&store_path);
|
||||||
|
let profile = store.get("legacy").unwrap();
|
||||||
|
|
||||||
|
// Reused the existing "path" declaration rather than creating "requests.path".
|
||||||
|
assert_eq!(profile.params.len(), 1);
|
||||||
|
assert_eq!(profile.params[0].name, "path");
|
||||||
|
assert_eq!(profile.metrics[0].label_params, vec!["path".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migration is idempotent: loading an already-migrated (or never-legacy) profile a second time
|
||||||
|
/// doesn't change anything or re-trigger a save.
|
||||||
|
#[test]
|
||||||
|
fn migration_is_a_noop_on_an_already_migrated_profile() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store_path = dir.path().join("profiles.json");
|
||||||
|
let mut settings = Settings::default();
|
||||||
|
let mut store = ProfileStore::load(&store_path);
|
||||||
|
store.create(&store_path, &mut settings, &dir.path().join("settings.json"), "web".to_string()).unwrap();
|
||||||
|
store
|
||||||
|
.record_param_declaration(
|
||||||
|
&store_path,
|
||||||
|
"web",
|
||||||
|
¶m_request("path", r"path=(?P<path>\S+)", "path"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.record_metric_declaration(&store_path, "web", &metric_request("requests", r"path=(?P<path>\S+)", "path"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let before = store.get("web").unwrap().clone();
|
||||||
|
let reloaded = ProfileStore::load(&store_path);
|
||||||
|
let after = reloaded.get("web").unwrap().clone();
|
||||||
|
assert_eq!(before, after);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import {
|
|||||||
declareMetric,
|
declareMetric,
|
||||||
fetchMetricBucketValues,
|
fetchMetricBucketValues,
|
||||||
fetchMetricDeclarations,
|
fetchMetricDeclarations,
|
||||||
|
fetchParamDeclarations,
|
||||||
metricPrimitiveToDisplayString,
|
metricPrimitiveToDisplayString,
|
||||||
updateMetric,
|
updateMetric,
|
||||||
type BucketValue,
|
type BucketValue,
|
||||||
type MetricDeclaration,
|
type MetricDeclaration,
|
||||||
type MetricParamMatcher,
|
type ParamDeclaration,
|
||||||
} from '../lib/api'
|
} from '../lib/api'
|
||||||
import {
|
import {
|
||||||
CollapseToggle,
|
CollapseToggle,
|
||||||
@@ -22,8 +23,6 @@ import {
|
|||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
import { RegexHelpIcon } from './RegexHelpIcon'
|
import { RegexHelpIcon } from './RegexHelpIcon'
|
||||||
|
|
||||||
const EMPTY_MATCHER: MetricParamMatcher = { param_name: '', capture_group: '', kind: 'string' }
|
|
||||||
|
|
||||||
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
// One row in the labels -> occurrence count table; `values` keeps the raw points for the sparkline rendering.
|
||||||
interface LabelGroup {
|
interface LabelGroup {
|
||||||
key: string
|
key: string
|
||||||
@@ -80,12 +79,16 @@ function sparkline(values: BucketValue[], range: TimeRange | null): string {
|
|||||||
|
|
||||||
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
|
export function Metrics({ range, activePreset }: { range: TimeRange | null; activePreset?: TimeRangePresetId | null }) {
|
||||||
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
const [metrics, setMetrics] = useState<MetricDeclaration[]>([])
|
||||||
|
const [params, setParams] = useState<ParamDeclaration[]>([])
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
const [bucketValues, setBucketValues] = useState<BucketValue[]>([])
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [regex, setRegex] = useState('')
|
const [regex, setRegex] = useState('')
|
||||||
const [matchers, setMatchers] = useState<MetricParamMatcher[]>([{ ...EMPTY_MATCHER }])
|
// A metric picks its label/value params from the params already declared in the Params panel
|
||||||
|
// (ticket 037) rather than re-entering a regex/capture-group/kind per param.
|
||||||
|
const [labelParams, setLabelParams] = useState<string[]>([])
|
||||||
|
const [valueParam, setValueParam] = useState<string>('')
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
const [collapsed, setCollapsed] = useSectionCollapsed('metrics')
|
||||||
@@ -101,8 +104,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadParams() {
|
||||||
|
try {
|
||||||
|
setParams(await fetchParamDeclarations())
|
||||||
|
} catch {
|
||||||
|
// engine not reachable yet; leave the previous list in place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMetrics()
|
loadMetrics()
|
||||||
|
loadParams()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
|
// Refetch-on-change rather than continuous polling: bucket values only move when the selected metric or range changes, so no interval is needed here like `App.tsx`'s log/timeline polling.
|
||||||
@@ -124,23 +136,18 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}
|
}
|
||||||
}, [selected, range])
|
}, [selected, range])
|
||||||
|
|
||||||
function updateMatcher(index: number, patch: Partial<MetricParamMatcher>) {
|
function toggleLabelParam(paramName: string) {
|
||||||
setMatchers((prev) => prev.map((m, i) => (i === index ? { ...m, ...patch } : m)))
|
setLabelParams((prev) =>
|
||||||
}
|
prev.includes(paramName) ? prev.filter((name) => name !== paramName) : [...prev, paramName],
|
||||||
|
)
|
||||||
function addMatcher() {
|
|
||||||
setMatchers((prev) => [...prev, { ...EMPTY_MATCHER }])
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMatcher(index: number) {
|
|
||||||
setMatchers((prev) => prev.filter((_, i) => i !== index))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setEditingName(null)
|
setEditingName(null)
|
||||||
setName('')
|
setName('')
|
||||||
setRegex('')
|
setRegex('')
|
||||||
setMatchers([{ ...EMPTY_MATCHER }])
|
setLabelParams([])
|
||||||
|
setValueParam('')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +156,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
setEditingName(metric.name)
|
setEditingName(metric.name)
|
||||||
setName(metric.name)
|
setName(metric.name)
|
||||||
setRegex(metric.message_match_regex)
|
setRegex(metric.message_match_regex)
|
||||||
setMatchers(metric.params.length > 0 ? metric.params.map((p) => ({ ...p })) : [{ ...EMPTY_MATCHER }])
|
setLabelParams(metric.label_params)
|
||||||
|
setValueParam(metric.value_param ?? '')
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +174,8 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
const request: MetricDeclaration = {
|
const request: MetricDeclaration = {
|
||||||
name,
|
name,
|
||||||
message_match_regex: regex,
|
message_match_regex: regex,
|
||||||
params: matchers.filter((m) => m.param_name && m.capture_group),
|
label_params: labelParams,
|
||||||
|
value_param: valueParam || null,
|
||||||
}
|
}
|
||||||
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
const saved = editingName ? await updateMetric(editingName, request) : await declareMetric(request)
|
||||||
resetForm()
|
resetForm()
|
||||||
@@ -227,61 +236,49 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center justify-between text-xs text-text-subtle">
|
<label className="mb-1 block text-xs text-text-subtle">
|
||||||
<span>Params</span>
|
Label params
|
||||||
<button
|
{params.length === 0 && (
|
||||||
type="button"
|
<span className="ml-1 text-text-faint">(declare a param in the Params panel below first)</span>
|
||||||
onClick={addMatcher}
|
)}
|
||||||
className="rounded border border-border-soft px-2 py-0.5 text-text-secondary hover:bg-surface-2"
|
</label>
|
||||||
>
|
<div className="max-h-32 space-y-0.5 overflow-auto rounded border border-border-soft bg-surface-1 p-1.5">
|
||||||
+ Add
|
{params.length === 0 ? (
|
||||||
</button>
|
<span className="text-xs text-text-faint">No params declared yet.</span>
|
||||||
|
) : (
|
||||||
|
params.map((p) => (
|
||||||
|
<label key={p.name} className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={labelParams.includes(p.name)}
|
||||||
|
onChange={() => toggleLabelParam(p.name)}
|
||||||
|
/>
|
||||||
|
<span className="font-mono">{p.name}</span>
|
||||||
|
<span className="text-text-faint">({p.kind})</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
{matchers.map((matcher, i) => (
|
<div>
|
||||||
<div key={i} className="flex items-center gap-1">
|
<label
|
||||||
<input
|
className="mb-1 block text-xs text-text-subtle"
|
||||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
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."
|
||||||
value={matcher.param_name}
|
>
|
||||||
onChange={(e) => updateMatcher(i, { param_name: e.target.value })}
|
Value param
|
||||||
placeholder="param_name"
|
</label>
|
||||||
/>
|
<select
|
||||||
<input
|
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"
|
||||||
className="w-24 min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
value={valueParam}
|
||||||
value={matcher.capture_group}
|
onChange={(e) => setValueParam(e.target.value)}
|
||||||
onChange={(e) => updateMatcher(i, { capture_group: e.target.value })}
|
>
|
||||||
placeholder="capture_group"
|
<option value="">(none — occurrence count)</option>
|
||||||
/>
|
{params.map((p) => (
|
||||||
<select
|
<option key={p.name} value={p.name}>
|
||||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
{p.name} ({p.kind})
|
||||||
value={matcher.kind}
|
</option>
|
||||||
onChange={(e) => updateMatcher(i, { kind: e.target.value as MetricParamMatcher['kind'] })}
|
|
||||||
>
|
|
||||||
<option value="string">string</option>
|
|
||||||
<option value="number">number</option>
|
|
||||||
<option value="timestamp">timestamp</option>
|
|
||||||
<option value="duration">duration</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="rounded border border-border-soft bg-surface-1 px-1 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
|
|
||||||
value={matcher.role ?? 'label'}
|
|
||||||
onChange={(e) => updateMatcher(i, { role: e.target.value as MetricParamMatcher['role'] })}
|
|
||||||
title="Label params identify a bucket; at most one Value param becomes the counter's per-event captured value (used by histogram()/gauge()). A Value param of kind 'duration' is derived automatically — elapsed time since the previous match with the same labels — rather than parsed from the capture group."
|
|
||||||
>
|
|
||||||
<option value="label">label</option>
|
|
||||||
<option value="value">value</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeMatcher(i)}
|
|
||||||
disabled={matchers.length === 1}
|
|
||||||
className="rounded border border-border-soft px-1.5 py-1 text-xs text-text-muted hover:bg-surface-2 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{editingName && (
|
{editingName && (
|
||||||
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
<div className="rounded border border-accent bg-accent/5 px-2 py-1 text-xs text-accent-text">
|
||||||
@@ -325,7 +322,7 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
}`}
|
}`}
|
||||||
title={m.message_match_regex}
|
title={m.message_match_regex}
|
||||||
>
|
>
|
||||||
{m.name} ({m.params.map((p) => p.param_name).join(', ') || 'no params'})
|
{m.name} ({m.label_params.join(', ') || 'no params'})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -342,17 +339,17 @@ export function Metrics({ range, activePreset }: { range: TimeRange | null; acti
|
|||||||
<span className="font-mono">{selectedMetric.message_match_regex}</span>
|
<span className="font-mono">{selectedMetric.message_match_regex}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-subtle">Params: </span>
|
<span className="text-text-subtle">Label params: </span>
|
||||||
{selectedMetric.params.length === 0 ? (
|
{selectedMetric.label_params.length === 0 ? (
|
||||||
<span className="text-text-faint">none</span>
|
<span className="text-text-faint">none</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono">
|
<span className="font-mono">{selectedMetric.label_params.join(', ')}</span>
|
||||||
{selectedMetric.params
|
|
||||||
.map((p) => `${p.param_name}←${p.capture_group} (${p.kind}, ${p.role ?? 'label'})`)
|
|
||||||
.join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-text-subtle">Value param: </span>
|
||||||
|
<span className="font-mono">{selectedMetric.value_param ?? <span className="text-text-faint">none</span>}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
|
|||||||
+4
-13
@@ -47,22 +47,13 @@ export interface Stats {
|
|||||||
approx_bytes_total: number
|
approx_bytes_total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParamRole = 'label' | 'value'
|
|
||||||
|
|
||||||
export interface MetricParamMatcher {
|
|
||||||
param_name: string
|
|
||||||
capture_group: string
|
|
||||||
/** See `ParamDeclaration.json_field_path`. */
|
|
||||||
json_field_path?: string | null
|
|
||||||
kind: 'string' | 'number' | 'timestamp' | 'duration'
|
|
||||||
/** Defaults to 'label' server-side when omitted. */
|
|
||||||
role?: ParamRole
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricDeclaration {
|
export interface MetricDeclaration {
|
||||||
name: string
|
name: string
|
||||||
message_match_regex: string
|
message_match_regex: string
|
||||||
params: MetricParamMatcher[]
|
/** Names of already-declared `ParamDeclaration`s (see the Params panel) whose values become this metric's bucket labels. */
|
||||||
|
label_params: string[]
|
||||||
|
/** Name of an already-declared param (if any) whose value becomes this metric's per-event bucket value instead of a label. */
|
||||||
|
value_param?: string | null
|
||||||
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
/** Fixed labels applied on a match/non-match, on top of any captured params. */
|
||||||
matched_labels?: Record<string, string> | null
|
matched_labels?: Record<string, string> | null
|
||||||
unmatched_labels?: Record<string, string> | null
|
unmatched_labels?: Record<string, string> | null
|
||||||
|
|||||||
Reference in New Issue
Block a user