feat(rum): funnel breakdown by any field, trend view and compare to an earlier period (#15645)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ashish Kolhe <35160958+oasisk@users.noreply.github.com>
This commit is contained in:
Omkar Kesarkhane
2026-10-31 15:18:55 +00:00
committed by GitHub
co-authored by github-actions[bot] Ashish Kolhe
parent 7ff9901a07
commit d510c65166
70 changed files with 12198 additions and 731 deletions
+149 -14
View File
@@ -21380,6 +21380,7 @@
"add": "81c5e9c7bb53cc85",
"funnelOnly": "718c4d6397114746",
"funnelTitle": "def5075533ff7f33",
"funnelTrendTitle": "b33250f5a57105c7",
"needsTwoSteps": "2b508475b3d896ed",
"snapshot": "127fa27601541b1f",
"step": "8e6a6cca7aae1d1e"
@@ -21611,18 +21612,49 @@
"alertPartial": "0c63f6c7c218102e",
"alertSource": "08d9890299e6380f",
"alertSourceNoApp": "fc063af055a3a9f3",
"alertTrend": "ce2c920894d2d1c3",
"alertWaiting": "530bfd8f1bb31445",
"atLeast": "e9213756a2e67dcb",
"breakdownLabel": "3ffe201a20342a4c",
"breakdownNone": "dc937b59892604f5",
"breakdownTimeHidden": "5d6f45fe981e37dc",
"breakDownByStep": "4f10ab4deb779079",
"breakdownAbout": "b1cdad1da9806151",
"breakdownAboutAny": "842b457e57a2b9fd",
"breakdownAboutAuto": "90e92263bbb903a1",
"breakdownAboutLeft": "8dafcac169f347a6",
"breakdownAboutPinned": "e661c2952adfd82d",
"breakdownAdd": "bed02a99659056c0",
"breakdownBy": "2a78f242ab5020b9",
"breakdownCaption": "92d24b39607213d5",
"breakdownFieldMissing": "c4b1585c2736a23d",
"breakdownOneAtATime": "122408dbab20b98b",
"breakdownPrevTooltip": "8e6e7179a8190a0f",
"breakdownRemove": "abe53e2c8504716e",
"breakdownReplace": "3b25bb55335427e7",
"breakdownValueFrom": "d2ed735e6132a019",
"builtinOnly": "093467b110cb5ce7",
"byAppRules": "3a37caa52df25a92",
"changeHeader": "9c6e8e0156fef278",
"changeHeaderTooltip": "db51f26d6813c942",
"changePts": "bff5e37b1f2e6b88",
"changeTooltip": "7111a2000b01417b",
"clear": "83b12c2216efb4fd",
"clearSteps": "f751acbdd806ccfc",
"coldDescription": "2c6f5fba43a79bfe",
"coldTitle": "895378b29592ac6c",
"collapseRail": "48b09313fd3e04c7",
"compareAgo1d": "961f4ea2623a7fd9",
"compareAgo1h": "e4bc2973fad40459",
"compareAgo1w": "b3a32d4c827ad4bd",
"compareEmpty": "dc478b15a65f24f6",
"compareNoLineAbout": "f953d041fded453a",
"compareNoLineBreakdown": "7046d2c049f42785",
"compareOff": "ca7981b46ecf2c17",
"compareOption": "fce297d23b6e718c",
"compareOptionDisabled": "6ca14a6398fff3cd",
"compareOverlaps": "ac411ee7cc3c427f",
"comparePrev": "6d23071bcd0cd538",
"compareSameAsPrev": "6adec20a56bccc5d",
"compareTo": "7c54b8f2d5e1b1c3",
"compareTooLong": "093f3dc5c4296129",
"condCredential": "402970ff5230bedd",
"condHosts": "780097afcd988f2f",
"condInvalid": "3e868d24a71557f7",
@@ -21646,6 +21678,7 @@
"suffix": "19ecc104ac1b52b3"
},
"conversion": "1f04a8531a463901",
"conversionFromStep": "1f8ac860136ad494",
"countBy": "6d9ca04b5373ad60",
"dashboardSnapshot": "d9594490b09920ea",
"deletedEvent": "fb4b2df95cfd8424",
@@ -21661,6 +21694,9 @@
},
"dropped": "f3e695c556d67055",
"droppedAria": "d8d0435326e3c9c7",
"droppedCompare": "b535f15e75e76513",
"droppedCompareAria": "4ca8a25a96241db6",
"droppedCompareTooltip": "baaaa8fb38ca257d",
"droppedTooltip": "6cc61297f0ea9184",
"editTooLongForLink": "ad66d5663c1a675d",
"entriesFailed": "fb4cb1dc93563c38",
@@ -21707,6 +21743,10 @@
"filterMinTime": "fff9acc4ed2d3ae1",
"filterNoField": "b762492c5f823ec6",
"filterNoFields": "c77f35a8396b3dce",
"filterNotice": "1933ace35b4539fd",
"filterNoticeAction": "61ffee2d6cb21ee2",
"filterNoticeReason": "56697bbf749b39f9",
"filterNoticeWhy": "57a2ea97a813cc6a",
"filterOnPage": "4b2a9a2f125522cb",
"filterPressEnter": "d045ff7872f414b3",
"filterRejected": "15f5f698fdf254ea",
@@ -21714,37 +21754,88 @@
"filterSelected": "c4b07f41437a6599",
"filterValuesFailed": "0c963becff830771",
"filterWhere": "b48111c10c65fc11",
"fromAnyStep": "6c75e31364d82135",
"fromAutomatic": "15e7d765e6850bf7",
"fromAutomaticNone": "d461a493a3753877",
"fromStep": "572d672bcbcb42da",
"fromStepItem": "566b6925b8defbf7",
"headlineBreakdown": "dcb07833b8e51ebe",
"headlineEstimated": "72ad362e05bd6fd7",
"headlineMedian": "b7b8988b58cf62c7",
"headlineWithin": "4f1ac942bb4dab15",
"identityHint": "2aaaccf30574f1bb",
"identityHintLink": "12cf486de9dde761",
"identityHintReason": "4dc0babcd6eb8006",
"intervalAuto": "0286249762f7c943",
"intervalAutoResolved": "be99031bd841e136",
"intervalDay": "8f2364e11b8be3ff",
"intervalMonth": "310ca503ef36f177",
"intervalNameDay": "944c27e5b97ab779",
"intervalNameMonth": "a5c7d1719e284f2c",
"intervalNameWeek": "6df37daaa46ce714",
"intervalWeek": "e78041ab51a818a2",
"leftBefore": "7cb2e07f11281052",
"leftOut": "33a891540eb0929a",
"lowVolume": "d7392969c01babc9",
"lowVolumeNote": "0c42a437548a338d",
"lowVolumeTag": "a2833abba88150b5",
"maxExclusions": "b9335161f4ab9d68",
"maxFilters": "46a2d58101e89a47",
"maxSteps": "68b44b546fcca5f2",
"medianTime": "86635b5efcc23b24",
"moveDown": "40bb50da160cdc21",
"moveUp": "c66feb5eb8f217c7",
"needsNewer": "ee4c0c52d90ca01c",
"nextStep": "097264ad2201b7ae",
"noBreakdown": "0cc7965c90ef7f5c",
"noneDropped": "9baaca8ec32c4a9c",
"notSeen": "88dfae91dfb57cbe",
"notSet": "34292f4267fcc405",
"ofFirst": "997bb8222d097f86",
"ofFirst": "fc47d72aaff201af",
"ofFirstCompareTooltip": "9c062ae30384508a",
"ofFirstTooltip": "c8f2be3c430bfc4f",
"ofPrevious": "5ab452d92a80fe45",
"ofPreviousTooltip": "cbf1ebfe11e770e9",
"ofPrevious": "69c8887b53b19b22",
"ofPreviousTooltip": "41c698ad34b7922b",
"onlyWhen": "3ddcbfe612b2f8d3",
"orderLabel": "b2391b3f14e8047a",
"other": "f97e9da0e3b879f0",
"overall": "5a0c07e7b91a0963",
"overallOf": "a70875b2de8f0254",
"overallTime": "af594831628cb460",
"overallVs": "e5cc796b89444c45",
"overallVsAgo": "b807c9504a475e5d",
"p90": "f1305bff5633e7c1",
"pickFirst": "db06fd0f8aaee98b",
"pickerAllFields": "b2525b1e37fccd0d",
"pickerKeyFields": "82721bb8faf1dfea",
"pickerManyValues": "eb64a0a0f4b8fadb",
"pickerOnStep": "228c6e9121694cf1",
"pickerOnSteps": "6653979843bee928",
"pickerSampleFailed": "4ddd70ceb9ae388d",
"pickerSampling": "f4bb44b880b9f8f2",
"pickerStepHint": "b7bce3cf2906fb4b",
"pickerStepHintMore": "9205b91729440263",
"pickerUser": "b512d97e7cbf97c2",
"pickerUserReason": "dde9dadeab2fab62",
"previousCount": "40fabefc2b1de312",
"previousFailed": "301c27f91a730db2",
"propConditions": {
"contains": "beeb20fbd6769508",
"in": "fa51fd49abf67705",
"notIn": "e1bf1560b1c24336",
"regex": "a54084383e3cf7c2"
},
"propertyAdd": "49ddf7c4d21883b4",
"propertyAddValue": "8e37953d23daca5f",
"propertyField": "f45fc1dfdc96db27",
"propertyFieldsFailed": "2de3609a08dde3b0",
"propertyFieldsLoading": "c4ecc5b2247dfac2",
"propertyInvalid": "675e566ab1a7f414",
"propertyNoFields": "de8dbcd0fd5e743a",
"propertyRemove": "4870f06022b65693",
"propertyTitle": "40040cb181f7f0ee",
"propertyValueCount": "1e3c53583737e574",
"railBreakdown": "3ffe201a20342a4c",
"railSettings": "74a883a037bc227f",
"railSteps": "1de3df70ddf4faab",
"railWindow": "19734a1b58cabc22",
"reachedNoValue": "794ce2dd498267c2",
"recent": "690dbe9dc0993c42",
"remove": "d4c76f463480c865",
"removeCond": "5ac0b06fc9f0d038",
@@ -21758,18 +21849,60 @@
"step": "8e6a6cca7aae1d1e",
"step1EmptyDescription": "eed26ddf894b391d",
"step1EmptyTitle": "4fb8686dfdfcf387",
"stepFieldMany": "3b68d256a3f32d52",
"stepFieldValues": "ff2ef73c7926bc4d",
"stepFilters": "8ecf2b2e95466ad3",
"stepMenu": "63adffd3e546cec1",
"stepsLoading": "ffe6a6578eea3a93",
"suggested": "93b63bab4c3a28d7",
"suggestedCaption": "b1b590fa7919b4a0",
"thisPeriod": "8ed3e11f85f561ce",
"timeFromFirst": "53d599b73f0e93a0",
"timeTooltip": "fb0925b1954839e9",
"tooLongForLink": "ec1a85f1e4b1db1e",
"total": "c9b3c38247f744e1",
"trendArriving": "88fbe06e4aa9b995",
"trendAverage": "7356ed3788622d58",
"trendAverageTooltip": "722694f77d12e53f",
"trendAxis": "b4dbd4a5e48bdec2",
"trendCaption": "911a83a663685b75",
"trendEmpty": "d4243d4882cd89f2",
"trendEmptyDescription": "852556a823199f1e",
"trendEntered": "82bf72604424aa01",
"trendFrom": "859e44a04eb10889",
"trendFromHere": "2bb5b70a17bb11e7",
"trendInterval": "6f45b0005e3b7c18",
"trendLastStep": "1a148e563e50b52c",
"trendLowVolume": "a2833abba88150b5",
"trendLowVolumeTooltip": "94e306c6467f2bb5",
"trendNoDataBefore": "d19efa6bade6fd8d",
"trendNoUnits": "091db1feebdc4ac5",
"trendPairOption": "7fb991266b475c4b",
"trendPartial": "a4d50fb854036548",
"trendPartialTooltip": "c2e1307873dff23b",
"trendPrevCell": "42301fea1f70cce2",
"trendPrevEmpty": "3a71001a0ca683a1",
"trendPrevSameDay": "5118c2cb84dbecd6",
"trendPrevSameMonth": "de9aaf92cdadd295",
"trendPrevSameWeek": "f1513d132be96f97",
"trendPrevSeriesOf": "4d7bab9d39bd6701",
"trendReached": "2ff05123d3c7392b",
"trendStillConverting": "8e5e425208ed570f",
"trendStillConvertingToday": "32fb90267c283978",
"trendStillConvertingTooltip": "135745af2daf9c0a",
"trendTo": "663ea1bfffe5038f",
"trendTooMany": "180c4fa7d8cebbbc",
"trendTooShort": "e61ccbb0320e39c6",
"trendTooltipPrev": "2d420fe630698d8c",
"trendTooltipRate": "53aa76405b151078",
"unitTooltip": "13c192e4b4a44f57",
"usersExact": "e2e9a093c085caf0",
"usersFallback": "5261a4aa0d099341",
"viewAs": "5787b5eda2ab9604",
"viewSteps": "1de3df70ddf4faab",
"viewTrend": "9e217716c4e03eee",
"where": "b48111c10c65fc11",
"whoFinishIn": "4f39ddda81e85213",
"whyApp": "f4d19be09fd818f4",
"whyAppFilters": "62d0f4985864ea3d",
"whyBots": "fa91ce7bc3c6e64b",
@@ -21786,10 +21919,12 @@
"whyStep1": "92dcdaad0820d15d",
"whySynthetic": "f668f1c8d2a22120",
"whyTitle": "865b4575e7a77af0",
"window1d": "635ed6bd6796642b",
"window1h": "b0c16d79225bf106",
"window7d": "b10f0b5ea1da77c1",
"windowSession": "f7a61eb02afe4f60"
"window1d": "fa665d95d29e83bc",
"window1h": "f8b8883f0c9d8d82",
"window7d": "7f920bb639c93075",
"windowOfStep1": "b60213cfbeeea2f9",
"windowSession": "50dcffdeed9a0dc8",
"windowSessionShort": "b828373b2bb21b53"
},
"identity": {
"absent": "923a9ae5620f9b88",
+273 -26
View File
@@ -59,6 +59,20 @@ const WINDOWS: [&str; 4] = ["session", "1h", "1d", "7d"];
const BREAKDOWNS: [&str; 7] = [
"browser", "os", "device", "country", "version", "env", "app",
];
/// `SessionField` names; a breakdown or step condition never names one as an attribute column.
const SESSION_FIELDS: [&str; 10] = [
"user_agent_user_agent_family",
"user_agent_os_family",
"user_agent_device_family",
"geo_info_country",
"geo_info_city",
"usr_id",
"usr_email",
"usr_anonymous_id",
"env",
"version",
];
const MAX_STEP_CONDS: usize = 10;
/// A rule must never select on a URL parameter that carries a credential.
const CREDENTIAL_PARAMS: [&str; 4] = ["id_token", "access_token", "code", "token"];
@@ -264,8 +278,12 @@ pub struct FunnelDef {
pub u: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub w: Option<String>,
/// A built-in key dimension, or any stream column but a session-filter field, env or version.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub b: Option<String>,
/// 1-based step an attribute `b` reads its value at; absent lets the data pick it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bs: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(value_type = Option<Vec<Object>>)]
pub x: Option<Vec<Exclusion>>,
@@ -303,7 +321,7 @@ impl Step {
}
}
/// A page step's filters: time on page (ms), `fr: 1`, `er: 1` and view conditions.
/// Step filters; only `pc`, the property conditions, applies to non-page steps.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StepOpts {
@@ -315,6 +333,14 @@ pub struct StepOpts {
pub er: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vc: Option<Vec<ViewCond>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pc: Vec<FunnelFilter>,
}
impl StepOpts {
fn page_only(&self) -> bool {
self.min.is_some() || self.fr.is_some() || self.er.is_some() || self.vc.is_some()
}
}
/// A page step's view condition: a URL parameter, or the referrer host, equals one of `values`.
@@ -344,7 +370,7 @@ pub struct Exclusion {
pub w: Option<i64>,
}
/// Filter all steps: keeps sessions where `field` matches one of `values`, or never does if `not`.
/// Matches `field` to `values`, inverted by `not`; `f` filters sessions with it, `pc` step events.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct FunnelFilter {
@@ -719,14 +745,7 @@ pub fn validate_def(def: &FunnelDef) -> Result<(serde_json::Value, Vec<String>),
WINDOWS.join(", ")
)));
}
if let Some(b) = def.b.as_deref()
&& !BREAKDOWNS.contains(&b)
{
return Err(invalid_def(format!(
"b must be one of {}",
BREAKDOWNS.join(", ")
)));
}
validate_breakdown(def)?;
let stored = FunnelDef {
s: def.s.clone(),
u: Some(unit.to_string()),
@@ -739,6 +758,7 @@ pub fn validate_def(def: &FunnelDef) -> Result<(serde_json::Value, Vec<String>),
.to_string(),
),
b: def.b.clone(),
bs: def.bs,
x: def.x.clone(),
f: def.f.clone(),
};
@@ -844,11 +864,12 @@ fn validate_step(step: &Step, event_ids: &mut Vec<String>) -> Result<(), RumPaEr
let Some(opts) = step.opts() else {
return Ok(());
};
if kind != "p" {
if kind != "p" && (opts.page_only() || opts.pc.is_empty()) {
return Err(invalid_def(
"step options apply only to p steps".to_string(),
"only property conditions apply to c, e and r steps".to_string(),
));
}
validate_step_conds(&opts.pc)?;
if opts
.min
.is_some_and(|min| !(0..=MAX_STEP_MS).contains(&min))
@@ -933,6 +954,61 @@ fn validate_filters(filters: Option<&[FunnelFilter]>) -> Result<(), RumPaError>
Ok(())
}
// A column named like a dimension reads as the dimension, as on the web.
fn validate_breakdown(def: &FunnelDef) -> Result<(), RumPaError> {
let attribute = match def.b.as_deref() {
None => false,
Some(b) if BREAKDOWNS.contains(&b) => false,
Some(b) if is_attribute_column(b) => true,
Some(_) => {
return Err(invalid_def(format!(
"b is one of {} or a stream column name",
BREAKDOWNS.join(", ")
)));
}
};
match def.bs {
None => Ok(()),
Some(_) if !attribute => Err(invalid_def(
"bs applies only to a stream column breakdown".to_string(),
)),
Some(bs) if bs >= 1 && usize::from(bs) <= def.s.len() => Ok(()),
Some(_) => Err(invalid_def(format!(
"bs is 1 to {} for this funnel",
def.s.len()
))),
}
}
fn validate_step_conds(conds: &[FunnelFilter]) -> Result<(), RumPaError> {
if conds.len() > MAX_STEP_CONDS {
return Err(invalid_def(format!(
"a step has at most {MAX_STEP_CONDS} property conditions"
)));
}
for cond in conds {
if !matches!(&cond.field, FilterField::Attribute(name) if is_attribute_column(name)) {
return Err(invalid_def(
"a property condition field is a stream column name".to_string(),
));
}
if cond.values.is_empty() || cond.values.len() > MAX_RULE_VALUES {
return Err(invalid_def(format!(
"a property condition has 1 to {MAX_RULE_VALUES} values"
)));
}
for value in &cond.values {
validate_match_value(value, cond.op == MatchOp::Regex).map_err(as_def)?;
}
}
Ok(())
}
/// A stream column that is not a built-in session field, as the web's `isFilterColumn` allows it.
fn is_attribute_column(name: &str) -> bool {
is_filter_column(name) && !SESSION_FIELDS.contains(&name)
}
// The name is quoted into SQL, so only what ingest writes passes; `_` columns are internal.
fn is_filter_column(name: &str) -> bool {
!name.is_empty()
@@ -1338,7 +1414,8 @@ mod tests {
let (stored, _) = validate_def(&def(&steps, None, None, Some(b))).unwrap();
assert_eq!(stored["b"], b);
}
assert!(validate_def(&def(&steps, None, None, Some("city"))).is_err());
assert!(validate_def(&def(&steps, None, None, Some("geo_info_city"))).is_err());
assert!(validate_def(&def(&steps, None, None, Some("City"))).is_err());
}
#[test]
@@ -1412,19 +1489,6 @@ mod tests {
);
}
const SESSION_FIELDS: [&str; 10] = [
"user_agent_user_agent_family",
"user_agent_os_family",
"user_agent_device_family",
"geo_info_country",
"geo_info_city",
"usr_id",
"usr_email",
"usr_anonymous_id",
"env",
"version",
];
fn settings_body(sessions: serde_json::Value, pages: serde_json::Value) -> serde_json::Value {
serde_json::json!({"bots": true, "sessions": sessions, "pages": pages, "version": 0})
}
@@ -1996,4 +2060,187 @@ mod tests {
assert_eq!(def_code(with_vc(vc)), "invalid_definition", "{key}");
}
}
fn p8_def(extra: serde_json::Value) -> Result<(serde_json::Value, Vec<String>), RumPaError> {
let mut def = serde_json::json!({"s": [["c", "product_viewed"], ["c", "payment_info_submitted"], ["c", "order_completed"]]});
for (k, v) in extra.as_object().unwrap() {
def[k] = v.clone();
}
validate_def(&fdef(def))
}
/// Steps `/a`, then `step`, with the given property conditions on `step`.
fn with_pc(
step: (&str, &str),
pc: serde_json::Value,
) -> Result<(serde_json::Value, Vec<String>), RumPaError> {
validate_def(&fdef(
serde_json::json!({"s": [["p", "/a"], [step.0, step.1, {"pc": pc}]]}),
))
}
fn pc_rule(field: &str, op: &str, values: serde_json::Value) -> serde_json::Value {
serde_json::json!({"field": field, "op": op, "values": values})
}
#[test]
fn p8_an_attribute_breakdown_is_a_stream_column_that_is_not_a_session_field() {
let (stored, _) = p8_def(serde_json::json!({"b": "context_payment_method"})).unwrap();
assert_eq!(stored["b"], "context_payment_method", "{stored}");
assert!(stored.get("bs").is_none(), "{stored}");
for b in ["Context_plan", "context-plan", "_timestamp", "", "a\"b"] {
assert_eq!(
def_code(p8_def(serde_json::json!({"b": b}))),
"invalid_definition",
"{b}"
);
}
// env and version are also dimension names, so they read as the dimension.
for b in SESSION_FIELDS.iter().filter(|f| !BREAKDOWNS.contains(f)) {
assert_eq!(
def_code(p8_def(serde_json::json!({"b": b}))),
"invalid_definition",
"{b}"
);
}
for b in BREAKDOWNS {
assert_eq!(def_code(p8_def(serde_json::json!({"b": b}))), "ok", "{b}");
}
}
#[test]
fn p8_bs_pins_a_step_of_an_attribute_breakdown() {
let (stored, _) =
p8_def(serde_json::json!({"b": "context_payment_method", "bs": 2})).unwrap();
assert_eq!(stored["bs"], 2, "{stored}");
assert_eq!(
def_code(p8_def(
serde_json::json!({"b": "context_payment_method", "bs": 3})
)),
"ok"
);
for bad in [
serde_json::json!({"bs": 1}),
serde_json::json!({"b": "browser", "bs": 1}),
serde_json::json!({"b": "context_payment_method", "bs": 0}),
serde_json::json!({"b": "context_payment_method", "bs": 4}),
] {
assert_eq!(def_code(p8_def(bad.clone())), "invalid_definition", "{bad}");
}
let def = serde_json::json!({"s": [["p", "/a"], ["p", "/b"]], "b": "context_x", "bs": 256});
assert!(serde_json::from_value::<FunnelDef>(def).is_err());
}
#[test]
fn p8_a_property_condition_rides_on_any_step_kind() {
let pc = serde_json::json!([pc_rule(
"context_payment_method",
"in",
serde_json::json!(["upi"])
)]);
for step in [("p", "/b"), ("c", "Buy"), ("e", ID_A), ("r", "Boom")] {
let (stored, _) = with_pc(step, pc.clone()).unwrap();
assert_eq!(stored["s"][1][2], serde_json::json!({"pc": pc}), "{stored}");
}
let not = serde_json::json!([{"field": "context_plan", "op": "regex", "values": ["^pro"], "not": true}]);
let (stored, _) = with_pc(("c", "Buy"), not.clone()).unwrap();
assert_eq!(stored["s"][1][2]["pc"], not, "{stored}");
let page = fdef(serde_json::json!({"s": [["p", "/a", {"min": 1, "pc": pc}], ["p", "/b"]]}));
assert_eq!(def_code(validate_def(&page)), "ok");
}
#[test]
fn p8_other_options_stay_on_page_steps() {
let pc = serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!(["pro"]))]);
for opts in [
serde_json::json!({"pc": pc, "min": 1}),
serde_json::json!({"pc": pc, "fr": 1}),
serde_json::json!({"pc": pc, "er": 1}),
serde_json::json!({"pc": []}),
serde_json::json!({}),
] {
let def = fdef(serde_json::json!({"s": [["p", "/a"], ["c", "Buy", opts]]}));
assert_eq!(def_code(validate_def(&def)), "invalid_definition", "{opts}");
}
}
#[test]
fn p8_property_conditions_are_bounded_like_filters() {
let rule = pc_rule("context_plan", "eq", serde_json::json!(["pro"]));
let twenty: Vec<String> = (0..20).map(|i| format!("v{i}")).collect();
let too_many: Vec<String> = (0..21).map(|i| format!("v{i}")).collect();
assert_eq!(
def_code(with_pc(
("c", "Buy"),
serde_json::json!(vec![rule.clone(); 10])
)),
"ok"
);
assert_eq!(
def_code(with_pc(
("c", "Buy"),
serde_json::json!([pc_rule("context_plan", "in", twenty.into())])
)),
"ok"
);
for pc in [
serde_json::json!(vec![rule.clone(); 11]),
serde_json::json!([pc_rule("context_plan", "in", serde_json::json!([]))]),
serde_json::json!([pc_rule("context_plan", "in", too_many.into())]),
serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!([""]))]),
serde_json::json!([pc_rule("context_plan", "regex", serde_json::json!(["(a"]))]),
serde_json::json!([pc_rule("Context_plan", "eq", serde_json::json!(["x"]))]),
serde_json::json!([pc_rule("geo_info_country", "eq", serde_json::json!(["x"]))]),
serde_json::json!([pc_rule("env", "eq", serde_json::json!(["x"]))]),
serde_json::json!([pc_rule("version", "eq", serde_json::json!(["x"]))]),
] {
assert_eq!(
def_code(with_pc(("c", "Buy"), pc.clone())),
"invalid_definition",
"{pc}"
);
}
let unknown = serde_json::json!({"s": [["p", "/a"], ["c", "Buy", {"pc": [{"field": "context_plan", "op": "eq", "values": ["x"], "y": 1}]}]]});
assert!(serde_json::from_value::<FunnelDef>(unknown).is_err());
}
#[test]
fn p8_a_definition_without_new_keys_serializes_unchanged() {
let def = serde_json::json!({
"s": [["p", "/a", {"min": 1000, "fr": 1, "er": 1, "vc": [{"t": "ref", "op": "eq", "values": ["google.com"]}]}], ["c", "Buy"], ["e", ID_A]],
"u": "users",
"w": "1d",
"b": "browser",
"x": [{"s": ["p", "/z"], "a": 1, "b": 2}],
"f": [{"field": "context_plan", "op": "in", "values": ["pro"]}]
});
let (stored, _) = validate_def(&fdef(def.clone())).unwrap();
assert_eq!(stored.to_string(), def.to_string());
let text = format!(
r#"{{"s":[["p","/a",{{"min":1000,"fr":1,"er":1,"vc":[{{"t":"ref","op":"eq","values":["google.com"]}}]}}],["c","Buy"],["e","{ID_A}"]],"u":"users","w":"1d","b":"browser","x":[{{"s":["p","/z"],"a":1,"b":2}}],"f":[{{"field":"context_plan","op":"in","values":["pro"]}}]}}"#
);
let parsed: FunnelDef = serde_json::from_str(&text).unwrap();
assert_eq!(serde_json::to_string(&parsed).unwrap(), text);
let plain = serde_json::json!({"s": [["p", "/a"], ["p", "/b"]]});
let (stored, _) = validate_def(&fdef(plain)).unwrap();
assert_eq!(
stored.to_string(),
r#"{"s":[["p","/a"],["p","/b"]],"u":"sessions","w":"session"}"#
);
}
#[test]
fn p8_an_exclusion_step_takes_no_property_conditions() {
let pc = serde_json::json!([pc_rule("context_plan", "eq", serde_json::json!(["pro"]))]);
for step in [
serde_json::json!(["c", "Buy", {"pc": pc}]),
serde_json::json!(["p", "/z", {"pc": pc}]),
serde_json::json!(["p", "/z", {"min": 1, "pc": pc}]),
] {
let x = serde_json::json!([{"s": step, "a": 1, "b": 3}]);
assert_eq!(def_code(with_x(x)), "invalid_definition", "{step}");
}
let plain = serde_json::json!([{"s": ["p", "/z"], "a": 1, "b": 3}]);
assert_eq!(def_code(with_x(plain)), "ok");
}
}
@@ -74,7 +74,6 @@ export class RumProductAnalyticsPage {
this.funnelAddStepBtn = page.locator('[data-test="rum-analytics-funnel-add-step-btn"]');
this.funnelAddStep = page.locator('[data-test="rum-analytics-funnel-add-step"]');
this.funnelAddDashboardBtn = page.locator('[data-test="rum-analytics-funnel-add-dashboard-btn"]');
this.breakdownSelect = page.locator('[data-test="rum-analytics-funnel-breakdown-select"]');
this.breakdown = page.locator('[data-test="rum-analytics-funnel-breakdown"]');
this.dropoffDrawer = page.locator('[data-test="rum-analytics-dropoff-drawer"]');
@@ -593,7 +592,12 @@ export class RumProductAnalyticsPage {
async pickConversionWindow(value) {
await this.pickOption('rum-analytics-funnel-window-select', { value });
await expect(this.funnelWindowTrigger).toHaveAttribute('data-test-selected-value', value);
await expect.poll(() => this.funnelFromUrl()?.w).toBe(value);
}
async pickBreakdown(dim) {
await this.page.locator('[data-test="rum-analytics-funnel-add-breakdown-btn"]').click();
await this.page.locator(`[data-test="rum-analytics-funnel-field-picker-dim-${dim}"]`).click();
}
dropoffSessionReplayTag(i) {
@@ -351,7 +351,7 @@ test.describe('RUM Product Analytics', () => {
const funnel = funnelParam([['p', '/web/a'], ['c', 'b-btn']]);
await pa.goto('funnels', { app: facts.appId, period: '7d', funnel });
await pa.expectFunnelCounts([6, 3]);
await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' });
await pa.pickBreakdown('browser');
await expect(pa.breakdown).toBeVisible({ timeout: 30000 });
await expect(pa.breakdown).toContainText('Chrome');
await expect(pa.breakdown).toContainText('Firefox');
@@ -663,7 +663,7 @@ test.describe('RUM Product Analytics', () => {
await expect(fresh.locator('[data-test="rum-analytics-funnel-saved-name"]')).toHaveText(name, { timeout: 30000 });
await reader.expectFunnelCounts([6, 3]);
await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' });
await pa.pickBreakdown('browser');
await expect(edited).toHaveText('Edited');
const put = pa.waitForAnalyticsWrite('funnel', 'PUT');
await page.locator('[data-test="rum-analytics-funnel-save-btn"]').click();
@@ -730,12 +730,12 @@ test.describe('RUM Product Analytics', () => {
await p.goto('funnels', { app: facts.appId, period: '7d', sf: row.id });
await expect(p.page.locator('[data-test="rum-analytics-funnel-saved-name"]')).toHaveText(name, { timeout: 30000 });
}
await first.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' });
await first.pickBreakdown('browser');
const put = first.waitForAnalyticsWrite('funnel', 'PUT');
await page.locator('[data-test="rum-analytics-funnel-save-btn"]').click();
expect((await put).status).toBe(200);
await second.pickOption('rum-analytics-funnel-breakdown-select', { value: 'os' });
await second.pickBreakdown('os');
const conflict = second.waitForAnalyticsWrite('funnel', 'PUT');
await other.locator('[data-test="rum-analytics-funnel-save-btn"]').click();
expect((await conflict).status).toBe(409);
@@ -790,7 +790,7 @@ test.describe('RUM Product Analytics', () => {
await page.unroute(list);
const unbroken = pa.query().get('funnel');
await pa.pickOption('rum-analytics-funnel-breakdown-select', { value: 'browser' });
await pa.pickBreakdown('browser');
await expect(edited).toHaveText('Edited');
// The URL is synced after the debounced recompute, so the edited funnel is read once it lands.
await expect.poll(() => pa.query().get('funnel')).not.toBe(unbroken);
@@ -1034,9 +1034,9 @@ test.describe('RUM Product Analytics', () => {
const pa = new PageManager(page).rumProductAnalyticsPage;
await pa.goto('funnels', { app: usersApp.appId, period: '7d', funnel: funnelParam([['p', '/web/a'], ['p', '/web/c']]) });
await pa.expectFunnelCounts([usersApp.sessionsAtA, 0]);
// Sessions are one window by definition, so Sessions mode shows a fixed tag, not a picker.
await expect(pa.funnelWindowFixed).toBeVisible();
// Sessions are one window by definition, so Sessions mode shows no window control.
await expect(pa.funnelWindowTrigger).toHaveCount(0);
await expect(pa.funnelWindowFixed).toHaveCount(0);
await pa.selectCountBy('users');
for (const w of ['session', '1h', '1d']) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import { createStore } from "vuex";
import FunnelBreakdown from "./FunnelBreakdown.vue";
import { raw } from "@/types/i18n";
import { foldBreakdown } from "@/utils/rum/productAnalyticsModel";
const store = createStore({ state: { theme: "light", timezone: "UTC" } });
const steps = [
{ kind: "c" as const, key: "product_viewed" },
{ kind: "c" as const, key: "checkout_started" },
{ kind: "c" as const, key: "payment_info_submitted" },
{ kind: "c" as const, key: "order_completed" },
];
const PAY = "context_payment_method";
const hits = [
{ dim: "card", c1: 20, c2: 18, c3: 17, c4: 17, dc1: 0, dc2: 0, dc3: 17, dc4: 17, mt3: 14000 },
{ dim: "upi", c1: 9, c2: 8, c3: 8, c4: 4, dc1: 0, dc2: 0, dc3: 8, dc4: 4, mt3: 11000 },
{ dim: null, c1: 60, c2: 30, c3: 3, c4: 1, dc1: 0, dc2: 0, dc3: 0, dc4: 0, mt3: 9000 },
];
const mountTable = async (fold: ReturnType<typeof foldBreakdown>) => {
const w = mount(FunnelBreakdown, {
props: {
rows: fold.rows,
total: fold.total,
steps,
dimLabel: raw(PAY),
events: [],
unitNoun: raw("sessions"),
start: fold.start,
},
global: {
plugins: [store],
stubs: {
OPopover: { template: '<div><slot name="trigger" /><slot /></div>' },
},
},
});
await flushPromises();
return w;
};
const cell = (w: Awaited<ReturnType<typeof mountTable>>, id: string) =>
w.find(`[data-test="rum-analytics-funnel-breakdown-${id}"]`);
describe("FunnelBreakdown with an attribute breakdown", () => {
it("rows run from the start step, with Reached no value, Left before and Total", async () => {
const fold = foldBreakdown(hits, 4, 5, { f: PAY });
expect(fold.start?.step).toBe(3);
const w = await mountTable(fold);
expect(cell(w, "row-0").text()).toBe("card");
expect(cell(w, "row-1").text()).toBe("upiLow volume");
expect(cell(w, "row-2").text()).toBe("Reached step 3, no value");
expect(cell(w, "row-3").text()).toBe("Left before step 3");
expect(cell(w, "row-4").text()).toBe("Total");
expect(w.text()).not.toContain("(not set)");
});
it("titles the table by field, leaves out the steps between 1 and k, and converts from k", async () => {
const w = await mountTable(foldBreakdown(hits, 4, 5, { f: PAY }));
expect(cell(w, "title").text()).toBe(`By ${PAY}`);
const headers = w.findAll("th").map((h) => h.text());
expect(headers).toEqual(
expect.arrayContaining([
"1. product_viewed",
"3. payment_info_submitted",
"4. order_completed",
"Conversion from step 3",
"Median time",
]),
);
expect(headers).not.toContain("2. checkout_started");
});
it("shows each value's median, and none for Left before or Total", async () => {
const w = await mountTable(foldBreakdown(hits, 4, 5, { f: PAY }));
expect(cell(w, "row-0-median").text()).toBe("14 s");
expect(cell(w, "row-1-median").text()).toBe("11 s");
expect(cell(w, "row-3-median").text()).toBe("—");
expect(cell(w, "row-4-median").text()).toBe("—");
});
it("marks values under 10 at step k as low volume, with a footnote, and never as the best", async () => {
const w = await mountTable(foldBreakdown(hits, 4, 5, { f: PAY }));
expect(cell(w, "row-0-low-volume").exists()).toBe(false);
expect(cell(w, "row-1-low-volume").exists()).toBe(true);
expect(cell(w, "low-volume-note").text()).toBe(
"Values below 10 sessions at step 3 are marked low volume",
);
});
it("explains the start step in an About popover", async () => {
const w = await mountTable(foldBreakdown(hits, 4, 5, { f: PAY }));
expect(cell(w, "about-text").text()).toContain(
`${PAY} is first set at step 3, payment_info_submitted, in 25 of the 28 sessions that reached it, so its rates start at step 3.`,
);
expect(cell(w, "about-text").text()).toContain(
"Those that left before step 3 have no value and are counted in one row.",
);
});
it("names a pinned step as picked", async () => {
const w = await mountTable(foldBreakdown(hits, 4, 5, { f: PAY, s: 4 }));
expect(cell(w, "about-text").text()).toContain(`${PAY} is read at step 4, order_completed`);
});
it("keeps the built-in rows' labels, with a median from step 1 and no About", async () => {
const w = await mountTable(foldBreakdown(hits, 4));
expect(cell(w, "row-2").text()).toBe("(not set)");
expect(cell(w, "about").exists()).toBe(false);
expect(w.findAll("th").map((h) => h.text())).toContain("2. checkout_started");
expect(w.findAll("th").map((h) => h.text())).toContain("Conversion from step 1");
});
});
@@ -15,7 +15,40 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div data-test="rum-analytics-funnel-breakdown">
<div class="flex flex-col" data-test="rum-analytics-funnel-breakdown">
<div class="flex items-center gap-2 px-4 pt-3 pb-1">
<span
class="text-text-heading text-sm font-semibold"
data-test="rum-analytics-funnel-breakdown-title"
>{{ t("rum.analytics.funnel.breakdownBy", { field: dimLabel }) }}</span
>
<OPopover
v-if="about"
align="start"
content-class="p-3 w-96 max-w-[calc(100vw-1.5rem)]"
:aria-label="t('rum.analytics.funnel.breakdownAbout')"
>
<template #trigger>
<OButton
variant="ghost-primary"
size="xs"
icon-left="info-outline"
data-test="rum-analytics-funnel-breakdown-about"
>{{ t("rum.analytics.funnel.breakdownAbout") }}</OButton
>
</template>
<div
class="text-text-body flex flex-col gap-2 text-sm"
data-test="rum-analytics-funnel-breakdown-about-text"
>
<span class="text-text-heading font-semibold">{{
t("rum.analytics.funnel.breakdownAbout")
}}</span>
<p>{{ about }}</p>
<p v-if="k > 1">{{ t("rum.analytics.funnel.breakdownAboutLeft", { step: k }) }}</p>
</div>
</OPopover>
</div>
<OTable
:data="tableRows"
:columns="columns"
@@ -31,9 +64,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="flex items-center gap-1.5"
:data-test="`rum-analytics-funnel-breakdown-row-${index}`"
>
<span :class="row.total ? 'text-text-heading font-semibold' : 'text-text-body'">{{
row.label
}}</span>
<span :class="labelClass(row)">{{ row.label }}</span>
<OTag
v-if="row.atLeast"
:label="t('rum.analytics.funnel.atLeast')"
@@ -46,20 +77,78 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="default-outline"
size="xs"
/>
<OTag
v-if="row.lowVolume"
:label="t('rum.analytics.funnel.lowVolumeTag')"
variant="default-soft"
size="xs"
:data-test="`rum-analytics-funnel-breakdown-row-${index}-low-volume`"
/>
</span>
</template>
<template v-for="(_, i) in steps" :key="i" #[`cell-s${i}`]="{ row }">
<span class="flex flex-col items-end tabular-nums">
<template v-for="i in shownSteps" :key="i" #[`cell-s${i}`]="{ row }">
<span v-if="row.prevOnly" class="text-text-secondary flex justify-end">{{ dash }}</span>
<span
v-else
class="flex flex-col items-end tabular-nums"
:class="row.leftBefore && i >= k - 1 ? 'text-text-secondary' : ''"
>
<span>{{ formatCount(row.counts[i], sampled) }}</span>
<span v-if="i > 0" class="text-text-secondary text-xs">{{
pct(row.counts[0] > 0 ? row.counts[i] / row.counts[0] : 0)
<span v-if="i > k - 1 && !row.leftBefore" class="text-text-secondary text-xs">{{
pct(row.counts[k - 1] > 0 ? row.counts[i] / row.counts[k - 1] : 0)
}}</span>
</span>
</template>
<template #cell-conversion="{ row }">
<ODataBarCell :value="row.conversion * 100" :max="100" :display="pct(row.conversion)" />
<span v-if="row.prevOnly || row.leftBefore" class="text-text-secondary flex justify-end">{{
dash
}}</span>
<ODataBarCell
v-else
:value="row.conversion * 100"
:max="100"
:display="pct(row.conversion)"
/>
</template>
<template #cell-median="{ row, index }">
<span
class="flex justify-end tabular-nums"
:class="row.medianMs === null ? 'text-text-secondary' : 'text-text-body'"
:data-test="`rum-analytics-funnel-breakdown-row-${index}-median`"
>{{ row.medianMs === null ? dash : formatDuration(row.medianMs) }}</span
>
</template>
<template #cell-prev="{ row, index }">
<span
class="flex justify-end tabular-nums"
:class="row.prev?.conversion == null ? 'text-text-secondary' : 'text-text-body'"
:data-test="`rum-analytics-funnel-breakdown-row-${index}-prev`"
>{{ row.prev?.conversion == null ? dash : pct(row.prev.conversion)
}}<OTooltip v-if="prevTip(row)" :content="prevTip(row)"
/></span>
</template>
<template #cell-change="{ row, index }">
<span
class="flex justify-end"
:data-test="`rum-analytics-funnel-breakdown-row-${index}-change`"
>
<FunnelChangeCell
:change="row.prev?.change ?? null"
:unit-noun="unitNoun"
:failed="compare?.status === 'failed'"
end
/>
</span>
</template>
</OTable>
<span
v-if="tableRows.some((r) => r.lowVolume)"
class="text-text-secondary px-4 py-2 text-end text-xs"
data-test="rum-analytics-funnel-breakdown-low-volume-note"
>{{
t("rum.analytics.funnel.lowVolumeNote", { count: LOW_VOLUME, unit: unitNoun, step: k })
}}</span
>
</div>
</template>
@@ -68,10 +157,26 @@ import { computed } from "vue";
import OTable from "@/lib/core/Table/OTable.vue";
import ODataBarCell from "@/lib/core/Table/cells/ODataBarCell.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import FunnelChangeCell from "@/components/rum/productAnalytics/funnelParts/FunnelChangeCell.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatCount, type BreakdownRow, type NamedEvent } from "@/utils/rum/productAnalyticsModel";
import { addCommasToNumber } from "@/utils/formatters";
import {
formatCount,
formatDuration,
type BreakdownRow,
type BreakdownStart,
type NamedEvent,
} from "@/utils/rum/productAnalyticsModel";
import type { SampleRatio, StepRef } from "@/utils/rum/productAnalyticsQueries";
import type {
BreakdownCompareState,
ComparedBreakdownRow,
PrevSide,
} from "@/utils/rum/funnelCompare";
import { stepLabel } from "@/utils/rum/productAnalyticsPanels";
interface TableRow {
@@ -82,11 +187,17 @@ interface TableRow {
atLeast: boolean;
total: boolean;
builtinOnly: boolean;
prev: PrevSide | null;
prevOnly: boolean;
leftBefore: boolean;
medianMs: number | null;
lowVolume: boolean;
best: boolean;
}
const props = withDefaults(
defineProps<{
rows: BreakdownRow[];
rows: (BreakdownRow | ComparedBreakdownRow)[];
total: number[];
steps: StepRef[];
dimLabel: I18nText;
@@ -94,35 +205,128 @@ const props = withDefaults(
sampled?: SampleRatio;
deletedNames?: Readonly<Record<string, string>>;
builtinOnly?: string[];
compare?: BreakdownCompareState | null;
unitNoun?: I18nText;
/** Where an attribute breakdown's rates start; null for a built-in one, which starts at step 1. */
start?: BreakdownStart | null;
}>(),
{ sampled: 1, deletedNames: () => ({}), builtinOnly: () => [] },
{
start: null,
sampled: 1,
deletedNames: () => ({}),
builtinOnly: () => [],
compare: null,
unitNoun: () => raw(""),
},
);
const { t } = useI18nTyped();
// Below this many units at the start step a rate swings on a handful of units.
const LOW_VOLUME = 10;
const pct = (v: number) => `${(v * 100).toFixed(1)}%`;
const dash = raw("—");
const k = computed(() => (props.start && !props.start.anyStep ? props.start.step : 1));
// Columns between step 1 and the start step only repeat the start step's count, so they are left out.
const shownSteps = computed(() =>
props.steps.map((_, i) => i).filter((i) => i === 0 || i >= k.value - 1),
);
const nameOf = (step: number) =>
props.steps[step - 1] ? stepLabel(props.steps[step - 1], props.events, props.deletedNames) : "";
const about = computed<I18nText | null>(() => {
const s = props.start;
if (!s) return null;
const args = {
field: props.dimLabel,
step: s.step,
name: raw(nameOf(s.step)),
covered: raw(addCommasToNumber(s.covered)),
reached: raw(addCommasToNumber(s.reached)),
unit: props.unitNoun,
};
if (s.anyStep) return t("rum.analytics.funnel.breakdownAboutAny", args);
return s.pinned
? t("rum.analytics.funnel.breakdownAboutPinned", args)
: t("rum.analytics.funnel.breakdownAboutAuto", args);
});
const prevTip = (row: TableRow): I18nText | undefined => {
if (props.compare?.status === "failed") return t("rum.analytics.funnel.previousFailed");
if (!row.prev || props.compare?.status !== "ok") return undefined;
return t("rum.analytics.funnel.breakdownPrevTooltip", {
last: formatCount(row.prev.last, props.sampled),
first: formatCount(row.prev.first, props.sampled),
unit: props.unitNoun,
range: raw(props.compare.prevRange),
});
};
// Only a finished previous run lines up with these rows; while it loads or after it fails every cell is a dash.
const prevOf = (r: BreakdownRow | ComparedBreakdownRow): PrevSide | null =>
props.compare?.status === "ok" && "prev" in r ? r.prev : null;
const rowLabel = (r: BreakdownRow): string => {
if (r.leftBefore) return t("rum.analytics.funnel.leftBefore", { step: k.value });
if (r.noValue) return t("rum.analytics.funnel.reachedNoValue", { step: k.value });
if (r.notSet) return t("rum.analytics.funnel.notSet");
return r.other ? t("rum.analytics.funnel.other") : r.label;
};
const isPrevOnly = (r: BreakdownRow | ComparedBreakdownRow) => "prevOnly" in r && r.prevOnly;
const isLow = (r: BreakdownRow | ComparedBreakdownRow) =>
r.value !== null && !isPrevOnly(r) && r.counts[k.value - 1] < LOW_VOLUME;
// One value converting best among those with enough volume; a tie names none.
const bestValue = computed<string | null>(() => {
const ranked = props.rows
.filter((r) => r.value !== null && !isPrevOnly(r) && !isLow(r))
.sort((a, b) => b.conversion - a.conversion);
if (ranked.length < 2 || ranked[0].conversion === ranked[1].conversion) return null;
return ranked[0].value;
});
const labelClass = (row: TableRow) => {
if (row.total || row.best) return "text-text-heading font-semibold";
return row.leftBefore ? "text-text-secondary" : "text-text-body";
};
const tableRows = computed<TableRow[]>(() => [
...props.rows.map((r, i) => ({
id: `r${i}`,
label: r.notSet
? t("rum.analytics.funnel.notSet")
: r.other
? t("rum.analytics.funnel.other")
: r.label,
label: rowLabel(r),
counts: r.counts,
conversion: r.conversion,
atLeast: r.atLeast,
total: false,
builtinOnly: !r.notSet && !r.other && props.builtinOnly.includes(r.label),
prev: prevOf(r),
prevOnly: isPrevOnly(r),
leftBefore: r.leftBefore,
medianMs: isPrevOnly(r) || r.leftBefore ? null : r.medianMs,
lowVolume: isLow(r),
best: r.value !== null && r.value === bestValue.value,
})),
{
id: "total",
label: t("rum.analytics.funnel.total"),
counts: props.total,
conversion: props.total[0] > 0 ? props.total[props.total.length - 1] / props.total[0] : 0,
conversion:
props.total[k.value - 1] > 0
? props.total[props.total.length - 1] / props.total[k.value - 1]
: 0,
atLeast: false,
total: true,
builtinOnly: false,
prev: props.compare?.status === "ok" ? props.compare.total : null,
prevOnly: false,
leftBefore: false,
medianMs: null,
lowVolume: false,
best: false,
},
]);
@@ -131,22 +335,47 @@ const columns = computed<OTableColumnDef<TableRow>[]>(() => [
id: "value",
header: props.dimLabel,
accessorKey: "label",
size: 200,
size: 160,
meta: { fillRemaining: true },
},
...props.steps.map((s, i) => ({
...shownSteps.value.map((i) => ({
id: `s${i}`,
header: raw(`${i + 1}. ${stepLabel(s, props.events, props.deletedNames)}`),
header: raw(`${i + 1}. ${stepLabel(props.steps[i], props.events, props.deletedNames)}`),
accessorFn: (r: TableRow) => r.counts[i],
size: 130,
size: 120,
meta: { align: "right" as const },
})),
{
id: "conversion",
header: t("rum.analytics.funnel.conversion"),
header: t("rum.analytics.funnel.conversionFromStep", { step: k.value }),
accessorKey: "conversion",
size: 150,
meta: { align: "right" },
},
{
id: "median",
header: t("rum.analytics.funnel.medianTime"),
accessorFn: (r: TableRow) => r.medianMs,
size: 100,
meta: { align: "right" },
},
...(props.compare
? [
{
id: "prev",
header: props.compare.label,
accessorFn: (r: TableRow) => r.prev?.conversion ?? null,
size: 110,
meta: { align: "right" as const },
},
{
id: "change",
header: t("rum.analytics.funnel.changeHeader"),
accessorFn: (r: TableRow) => r.prev?.change?.pts ?? null,
size: 140,
meta: { align: "right" as const },
},
]
: []),
]);
</script>
@@ -0,0 +1,286 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import { createStore } from "vuex";
import FunnelTrend from "./FunnelTrend.vue";
import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import { raw } from "@/types/i18n";
import {
alignPrevious,
foldTrend,
trendPeriods,
type TrendContext,
type TrendPeriods,
} from "@/utils/rum/funnelTrend";
const HOUR = 3_600_000_000;
const DAY = 24 * HOUR;
const D8 = Date.UTC(2026, 9, 8) * 1000;
const NOW = D8 + 7 * DAY + 10 * HOUR;
const store = createStore({ state: { theme: "light", timezone: "UTC" } });
const periodsOf = (days: number, start = D8): TrendPeriods => {
const p = trendPeriods({ startUs: start, endUs: start + days * DAY }, "UTC", NOW, "day");
if (!("boundariesUs" in p)) throw new Error("expected periods");
return p;
};
const ctx: TrendContext = {
range: { startUs: D8, endUs: D8 + 8 * DAY },
nowUs: NOW,
dataStartUs: null,
dataThroughUs: null,
windowUs: null,
};
const state = (patch: Partial<PanelState<unknown>> = {}): PanelState<unknown> => ({
status: "ok",
rows: [],
error: null,
partial: null,
key: "k",
sampled: 1,
...patch,
});
const rows = [
{ bk: 1, ca: 5, cb: 1 },
{ bk: 3, ca: 7, cb: 4 },
{ bk: 5, ca: 52, cb: 21 },
{ bk: 7, ca: 11, cb: 4 },
];
type ChartStub = { props: (k: string) => { options: Record<string, any> } };
const mountTrend = async (props: Record<string, unknown> = {}) => {
const periods = (props.periods as TrendPeriods | undefined) ?? periodsOf(8);
const w = mount(FunnelTrend, {
props: {
state: state(),
series: foldTrend(rows, periods, ctx, false),
periods,
pair: { a: 1, b: 6 },
pairLabel: raw("Step 1 → Step 6"),
timezone: "UTC",
range: "8–15 Oct",
unitNoun: raw("sessions"),
...props,
},
global: {
plugins: [store],
stubs: {
ChartRenderer: {
name: "ChartRenderer",
props: ["data"],
template: "<div data-test='chart-stub' />",
},
},
},
});
await flushPromises();
return w;
};
const cell = (w: Awaited<ReturnType<typeof mountTrend>>, dt: string) =>
w.find(`[data-test="rum-analytics-funnel-trend-row-${dt}"]`);
describe("FunnelTrend (P0-9)", () => {
it("shows a skeleton while the trend runs and an error with retry when it fails", async () => {
const loading = await mountTrend({ state: state({ status: "loading" }), series: [] });
expect(loading.find('[data-test="rum-analytics-funnel-trend-loading"]').exists()).toBe(true);
expect(loading.find('[data-test="chart-stub"]').exists()).toBe(false);
const failed = await mountTrend({
state: state({ status: "error", error: { message: "boom" } }),
series: [],
});
await failed.find('[data-test="rum-analytics-funnel-trend-retry-btn"]').trigger("click");
expect(failed.emitted("retry")).toHaveLength(1);
});
it("says why when the range cannot make a trend", async () => {
const w = await mountTrend({ issue: "tooShort" });
expect(w.find('[data-test="rum-analytics-funnel-trend-range-issue"]').text()).toContain(
"at least 2 days",
);
});
it("an empty range says no sessions instead of drawing zeros", async () => {
const w = await mountTrend({ series: foldTrend([], periodsOf(8), ctx, false) });
expect(w.find('[data-test="rum-analytics-funnel-trend-empty"]').text()).toContain(
"No sessions in this range",
);
expect(w.find('[data-test="chart-stub"]').exists()).toBe(false);
});
it("lists every day with gaps in words, still-converting and low-volume tags, and the zone", async () => {
const w = await mountTrend();
expect(w.find('[data-test="rum-analytics-funnel-trend-caption"]').text()).toBe(
"8–15 Oct · UTC",
);
const head = w.find('[data-test="rum-analytics-funnel-trend-table"] thead').text();
for (const h of ["Day", "Entered step 1", "Reached step 6", "Conversion"])
expect(head).toContain(h);
expect(head).not.toContain("Previous period");
expect(cell(w, "0-label").text()).toBe("8 Oct");
expect(cell(w, "0-note").text()).toBe("No sessions");
expect(cell(w, "5-rate").text()).toContain("40.4%");
expect(cell(w, "1-low").exists()).toBe(true);
expect(cell(w, "5-low").exists()).toBe(false);
expect(cell(w, "7-still").text()).toBe("Still converting");
expect(cell(w, "6-still").exists()).toBe(false);
});
it("draws empty days as null points with no grey bands, the table keeping their notes, and today as a separate dashed, hollow point", async () => {
const w = await mountTrend();
const options = (w.findComponent({ name: "ChartRenderer" }) as unknown as ChartStub).props(
"data",
).options;
const [main, still] = options.series;
expect(main.connectNulls).toBe(false);
expect(main.data[0]).toEqual([0, null]);
expect(main.data[2]).toEqual([2, null]);
expect(main.data[7]).toEqual([7, null]);
for (const s of options.series) expect(s.markArea).toBeUndefined();
expect(cell(w, "0-note").text()).toBe("No sessions");
expect(cell(w, "2-note").text()).toBe("No sessions");
expect(still.name).toBe("Still converting (today)");
expect(still.lineStyle.type).toBe("dashed");
expect(still.data[7]).toEqual({ value: [7, 36.4] });
expect(still.data[6]).toBeNull();
expect(options.yAxis).toMatchObject({ min: 0, max: 100, name: "Conversion (%)" });
});
it("with compare, adds the previous period as a dashed grey line, keeps the pair in the series name, and names each matching day", async () => {
const periods = periodsOf(8);
const [cur] = foldTrend(rows, periods, ctx, false);
const prevPeriods = periodsOf(8, D8 - 8 * DAY);
const [prev] = foldTrend([{ bk: 1, ca: 5, cb: 1 }], prevPeriods, ctx, false);
const w = await mountTrend({
previous: {
status: "ok",
buckets: alignPrevious(cur, prev),
range: "30 Sep–7 Oct",
label: raw("Previous period"),
},
});
expect(w.find('[data-test="rum-analytics-funnel-trend-table"] thead').text()).toContain(
"Previous period, same day",
);
expect(cell(w, "0-previous").text()).toBe("30 Sep · no sessions");
expect(cell(w, "1-previous").text()).toBe("1 Oct · 20.0%");
const options = (w.findComponent({ name: "ChartRenderer" }) as unknown as ChartStub).props(
"data",
).options;
const last = options.series.at(-1);
expect(last.name).toBe("30 Sep–7 Oct (Previous period)");
expect(last.lineStyle.type).toBe("dashed");
expect(options.series[0].name).toBe("Step 1 → Step 6 · 8–15 Oct");
});
it("with compare set to an offset, names the dashed line with its dates and the offset", async () => {
const periods = periodsOf(8);
const [cur] = foldTrend(rows, periods, ctx, false);
const prevPeriods = periodsOf(8, D8 - 7 * DAY);
const [prev] = foldTrend([{ bk: 1, ca: 5, cb: 1 }], prevPeriods, ctx, false);
const w = await mountTrend({
previous: {
status: "ok",
buckets: alignPrevious(cur, prev),
range: "1–8 Oct",
label: "1 week ago",
},
});
const options = (w.findComponent({ name: "ChartRenderer" }) as unknown as ChartStub).props(
"data",
).options;
const last = options.series.at(-1);
expect(last.name).toBe("1–8 Oct (1 week ago)");
expect(last.lineStyle.type).toBe("dashed");
});
it("a breakdown draws no grey bands; a value's empty day is a null point on its own line", async () => {
const periods = periodsOf(8);
const hits = [
{ dim: "Chrome", un: 0, tot: 90, bk: 1, ca: 10, cb: 5 },
{ dim: "Safari", un: 0, tot: 40, bk: 1, ca: 4, cb: 2 },
{ dim: "Safari", un: 0, tot: 40, bk: 2, ca: 40, cb: 8 },
];
const w = await mountTrend({
breakdown: true,
dimLabel: raw("Browser"),
periods,
series: foldTrend(hits, periods, ctx, true),
});
const options = (w.findComponent({ name: "ChartRenderer" }) as unknown as ChartStub).props(
"data",
).options;
for (const s of options.series) expect(s.markArea).toBeUndefined();
const line = (name: string) => options.series.find((s: { name: string }) => s.name === name);
expect(line("Chrome").data[0]).toEqual([0, null]);
expect(line("Chrome").data[2]).toEqual([2, null]);
expect(line("Safari").data[2]).toEqual([2, 20]);
});
it("the pivot marks every still-converting day, as Users mode's window leaves several open", async () => {
const periods = periodsOf(8);
const hits = [{ dim: "Chrome", un: 0, tot: 9, bk: 1, ca: 10, cb: 5 }];
const w = await mountTrend({
breakdown: true,
dimLabel: raw("Browser"),
periods,
series: foldTrend(hits, periods, { ...ctx, windowUs: DAY }, true),
today: false,
});
const head = w.find('[data-test="rum-analytics-funnel-trend-table"] thead').text();
expect(head).toContain("14 Oct · Still converting");
expect(head).toContain("15 Oct · Still converting");
expect(head).not.toContain("13 Oct · Still converting");
});
it("a breakdown pivots value × day up to 8 days, and past that shows averages without today", async () => {
const hits = [
{ dim: "Chrome", un: 0, bk: 1, ca: 10, cb: 5 },
{ dim: "Chrome", un: 0, bk: 7, ca: 4, cb: 0 },
{ dim: "Firefox", un: 0, bk: 1, ca: 4, cb: 4 },
];
const short = periodsOf(8);
const pivot = await mountTrend({
breakdown: true,
dimLabel: raw("Browser"),
periods: short,
series: foldTrend(hits, short, ctx, true),
});
const head = pivot.find('[data-test="rum-analytics-funnel-trend-table"] thead').text();
expect(head).toContain("Browser");
expect(head).toContain("15 Oct · Still converting (today)");
expect(cell(pivot, "v0-b1").text()).toBe("50.0%");
expect(cell(pivot, "v0-b0").text()).toBe("—");
const long = periodsOf(10, D8 - 2 * DAY);
const avg = await mountTrend({
breakdown: true,
dimLabel: raw("Browser"),
periods: long,
series: foldTrend(
hits.map((h) => ({ ...h, bk: h.bk + 2 })),
long,
{ ...ctx, range: { startUs: D8 - 2 * DAY, endUs: D8 + 8 * DAY } },
true,
),
});
const avgHead = avg.find('[data-test="rum-analytics-funnel-trend-table"] thead').text();
expect(avgHead).toContain("Average conversion");
expect(cell(avg, "v0-label").text()).toBe("Chrome");
expect(cell(avg, "v0-rate").text()).toContain("50.0%");
});
});
@@ -0,0 +1,457 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="flex min-w-0 flex-col" data-test="rum-analytics-funnel-trend">
<OEmptyState
v-if="issue"
preset="no-search-results"
size="block"
:title="
issue === 'tooShort'
? t('rum.analytics.funnel.trendTooShort')
: t('rum.analytics.funnel.trendTooMany')
"
data-test="rum-analytics-funnel-trend-range-issue"
/>
<AnalyticsPanelState
v-else
:state="state"
data-test="rum-analytics-funnel-trend"
class="px-4 py-2"
@retry="emit('retry')"
>
<OEmptyState
v-if="empty"
preset="no-search-results"
size="block"
:title="t('rum.analytics.funnel.trendEmpty', { unit: unitNoun })"
:description="t('rum.analytics.funnel.trendEmptyDescription', { step: pair.a })"
data-test="rum-analytics-funnel-trend-empty"
/>
<template v-else-if="periods">
<!-- ChartRenderer, not PanelSchemaRenderer: the fixed bucket grid keeps empty buckets as banded gaps. -->
<div class="h-64 w-full" data-test="rum-analytics-funnel-trend-chart">
<ChartRenderer :data="{ options: chartOptions }" class="h-full w-full" />
</div>
<span
class="text-text-secondary pb-2 text-xs"
data-test="rum-analytics-funnel-trend-caption"
>{{
t("rum.analytics.funnel.trendCaption", { range: raw(range), tz: raw(timezone) })
}}</span
>
<div class="-mx-4 min-w-0 overflow-x-auto" data-test="rum-analytics-funnel-trend-table">
<OTable
:data="rows"
:columns="columns"
:default-columns="false"
row-key="id"
pagination="none"
sorting="none"
dense
:show-global-filter="false"
:frame="false"
>
<template #cell-bucket="{ row }">
<span
class="flex flex-wrap items-center gap-1.5"
:data-test="`${rowTest(row)}-label`"
>
<span :class="row.kind === 'bucket' ? 'text-text-body' : 'text-text-heading'">{{
row.label
}}</span>
<span v-for="tag in row.tags" :key="tag.id">
<OTag
:label="tag.label"
variant="default-soft"
size="xs"
:data-test="`${rowTest(row)}-${tag.id}`"
/>
<OTooltip v-if="tag.tip" :content="tag.tip" />
</span>
</span>
</template>
<template #cell-entered="{ row }">
<span
class="flex justify-end tabular-nums"
:class="row.entered === 0 ? 'text-text-secondary' : 'text-text-body'"
>{{ formatCount(row.entered ?? 0, 1) }}</span
>
</template>
<template #cell-reached="{ row }">
<span
class="flex justify-end tabular-nums"
:class="row.reached === 0 ? 'text-text-secondary' : 'text-text-body'"
>{{ formatCount(row.reached ?? 0, 1) }}</span
>
</template>
<template #cell-conversion="{ row }">
<span
v-if="row.note"
class="text-text-secondary flex justify-end"
:data-test="`${rowTest(row)}-note`"
>{{ row.note }}</span
>
<span v-else :data-test="`${rowTest(row)}-rate`">
<ODataBarCell
:value="(row.rate ?? 0) * 100"
:max="100"
:display="pct(row.rate ?? 0)"
/>
<OTooltip v-if="row.rateTip" :content="row.rateTip" />
</span>
</template>
<template #cell-previous="{ row }">
<span
class="text-text-secondary flex justify-end tabular-nums"
:data-test="`${rowTest(row)}-previous`"
>{{ row.previous }}<OTooltip v-if="previousTip" :content="previousTip"
/></span>
</template>
<template v-for="(c, i) in pivotColumns" :key="c" #[`cell-${c}`]="{ row }">
<span
class="flex justify-end tabular-nums"
:class="row.pivot?.[i] == null ? 'text-text-secondary' : 'text-text-body'"
:data-test="`${rowTest(row)}-${c}`"
>{{ row.pivot?.[i] == null ? dash : pct(row.pivot[i]!) }}</span
>
</template>
</OTable>
</div>
</template>
</AnalyticsPanelState>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from "vue";
import OTable from "@/lib/core/Table/OTable.vue";
import ODataBarCell from "@/lib/core/Table/cells/ODataBarCell.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import AnalyticsPanelState from "@/components/rum/productAnalytics/AnalyticsPanelState.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatCount } from "@/utils/rum/productAnalyticsModel";
import type { FunnelPair } from "@/utils/rum/productAnalyticsQueries";
import {
PIVOT_MAX_BUCKETS,
TREND_LOW_VOLUME,
bucketLabel,
isTrendEmpty,
trendAverage,
type TrendBucket,
type TrendPeriods,
type TrendPreviousView,
type TrendSeries,
} from "@/utils/rum/funnelTrend";
import { trendChartOptions } from "@/components/rum/productAnalytics/funnelParts/funnelTrendChart";
const ChartRenderer = defineAsyncComponent(
() => import("@/components/dashboards/panels/ChartRenderer.vue"),
);
interface Tag {
id: string;
label: I18nText;
tip?: I18nText;
}
interface Row {
id: string;
kind: "bucket" | "value";
label: I18nText;
tags: Tag[];
entered: number | null;
reached: number | null;
rate: number | null;
rateTip?: I18nText;
note: I18nText | null;
previous?: string;
pivot?: (number | null)[];
}
defineOptions({ name: "FunnelTrend" });
const props = withDefaults(
defineProps<{
state: PanelState<unknown>;
series: TrendSeries[];
periods: TrendPeriods | null;
issue?: "tooShort" | "tooMany" | null;
breakdown?: boolean;
pair: FunnelPair;
pairLabel: I18nText;
dimLabel?: I18nText;
previous?: TrendPreviousView | null;
timezone: string;
range: string;
unitNoun: I18nText;
dataStartUs?: number | null;
today?: boolean;
}>(),
{
issue: null,
breakdown: false,
dimLabel: () => raw(""),
previous: null,
dataStartUs: null,
today: true,
},
);
const emit = defineEmits<{ retry: [] }>();
const { t } = useI18nTyped();
const dash = raw("—");
const pct = (v: number) => `${(v * 100).toFixed(1)}%`;
const g = computed(() => props.periods?.granularity ?? "day");
const label = (us: number) => bucketLabel(us, props.timezone, g.value);
const labels = computed(() => props.periods?.boundariesUs.map(label) ?? []);
const empty = computed(() => props.state.status === "ok" && isTrendEmpty(props.series));
const pivot = computed(() => props.breakdown && labels.value.length <= PIVOT_MAX_BUCKETS);
const pivotColumns = computed(() => (pivot.value ? labels.value.map((_, i) => `b${i}`) : []));
const intervalName = computed(() =>
g.value === "week"
? t("rum.analytics.funnel.intervalNameWeek")
: g.value === "month"
? t("rum.analytics.funnel.intervalNameMonth")
: t("rum.analytics.funnel.intervalNameDay"),
);
const stillLabel = computed(() =>
props.today && g.value === "day"
? t("rum.analytics.funnel.trendStillConvertingToday")
: t("rum.analytics.funnel.trendStillConverting"),
);
const previousTip = computed<I18nText | undefined>(() =>
props.previous?.status === "failed" ? t("rum.analytics.funnel.previousFailed") : undefined,
);
const rowTest = (row: Row) => `rum-analytics-funnel-trend-row-${row.id}`;
// Before the first data, empty is expected; after the last event, it may only be late.
const bandLabel = (b: TrendBucket): I18nText | null => {
if (b.beforeData && props.dataStartUs !== null)
return t("rum.analytics.funnel.trendNoDataBefore", { date: label(props.dataStartUs) });
if (!b.empty) return null;
return b.arriving
? t("rum.analytics.funnel.trendArriving")
: t("rum.analytics.funnel.trendNoUnits", { unit: props.unitNoun });
};
const tagsOf = (b: TrendBucket): Tag[] => [
...(b.stillConverting
? [
{
id: "still",
label: t("rum.analytics.funnel.trendStillConverting"),
tip: t("rum.analytics.funnel.trendStillConvertingTooltip", {
unit: props.unitNoun,
step: props.pair.b,
}),
},
]
: []),
...(b.partial && !b.stillConverting
? [
{
id: "partial",
label: t("rum.analytics.funnel.trendPartial"),
tip: t("rum.analytics.funnel.trendPartialTooltip", { interval: intervalName.value }),
},
]
: []),
...(b.arriving && !b.empty
? [{ id: "arriving", label: t("rum.analytics.funnel.trendArriving") }]
: []),
...(b.lowVolume && !b.beforeData
? [
{
id: "low",
label: t("rum.analytics.funnel.trendLowVolume"),
tip: t("rum.analytics.funnel.trendLowVolumeTooltip", {
n: TREND_LOW_VOLUME,
unit: props.unitNoun,
}),
},
]
: []),
];
const previousText = (i: number): string => {
const p = props.previous;
const b = p?.status === "ok" ? p.buckets[i] : null;
if (!b) return "—";
const date = label(b.startUs);
return b.rate === null
? String(t("rum.analytics.funnel.trendPrevEmpty", { date, unit: props.unitNoun }))
: String(t("rum.analytics.funnel.trendPrevCell", { date, rate: pct(b.rate) }));
};
const seriesLabel = (s: TrendSeries): I18nText => {
if (s.kind === "other") return t("rum.analytics.funnel.other");
if (s.kind === "notSet") return t("rum.analytics.funnel.notSet");
return s.kind === "all" ? props.pairLabel : raw(s.value ?? "");
};
const bucketRows = computed<Row[]>(() =>
(props.series[0]?.buckets ?? []).map((b, i) => ({
id: String(i),
kind: "bucket",
label: raw(labels.value[i] ?? ""),
tags: tagsOf(b),
entered: b.entered,
reached: b.reached,
rate: b.rate,
note: b.rate === null || b.beforeData ? bandLabel(b) : null,
...(props.previous ? { previous: previousText(i) } : {}),
})),
);
const valueRows = computed<Row[]>(() =>
props.series.map((s, k) => {
const avg = trendAverage(s);
return {
id: `v${k}`,
kind: "value",
label: seriesLabel(s),
tags: [],
entered: avg.entered,
reached: avg.reached,
rate: avg.rate,
rateTip: t("rum.analytics.funnel.trendAverageTooltip", { interval: intervalName.value }),
note: avg.rate === null ? dash : null,
pivot: s.buckets.map((b) => (b.beforeData ? null : b.rate)),
};
}),
);
const rows = computed<Row[]>(() => (props.breakdown ? valueRows.value : bucketRows.value));
const bucketHeader = computed(() =>
g.value === "week"
? t("rum.analytics.funnel.intervalWeek")
: g.value === "month"
? t("rum.analytics.funnel.intervalMonth")
: t("rum.analytics.funnel.intervalDay"),
);
const previousHeader = computed(() =>
g.value === "week"
? t("rum.analytics.funnel.trendPrevSameWeek")
: g.value === "month"
? t("rum.analytics.funnel.trendPrevSameMonth")
: t("rum.analytics.funnel.trendPrevSameDay"),
);
const countColumns = computed<OTableColumnDef<Row>[]>(() => [
{
id: "entered",
header: t("rum.analytics.funnel.trendEntered", { n: props.pair.a }),
accessorKey: "entered",
size: 130,
meta: { align: "right" },
},
{
id: "reached",
header: t("rum.analytics.funnel.trendReached", { n: props.pair.b }),
accessorKey: "reached",
size: 130,
meta: { align: "right" },
},
]);
const columns = computed<OTableColumnDef<Row>[]>(() => {
const first: OTableColumnDef<Row> = {
id: "bucket",
header: props.breakdown ? props.dimLabel : bucketHeader.value,
accessorKey: "label",
size: 180,
meta: { fillRemaining: true },
};
if (pivot.value) {
const still = (i: number) => props.series.some((s) => s.buckets[i]?.stillConverting);
return [
first,
...labels.value.map((l, i) => ({
id: `b${i}`,
header: still(i) ? raw(`${l} · ${stillLabel.value}`) : raw(l),
accessorFn: (r: Row) => r.pivot?.[i] ?? null,
size: 100,
meta: { align: "right" as const },
})),
];
}
return [
first,
...countColumns.value,
{
id: "conversion",
header: props.breakdown
? t("rum.analytics.funnel.trendAverage")
: t("rum.analytics.funnel.conversion"),
accessorKey: "rate",
size: 150,
meta: { align: "right" },
},
...(props.previous && !props.breakdown
? [
{
id: "previous",
header: previousHeader.value,
accessorKey: "previous" as const,
size: 190,
meta: { align: "right" as const },
},
]
: []),
];
});
const chartOptions = computed(() =>
trendChartOptions(
{
labels: labels.value,
series: props.series.map((s) => ({
name:
s.kind === "all" && props.previous
? `${props.pairLabel} · ${props.range}`
: String(seriesLabel(s)),
data: s,
})),
previous:
props.previous?.status === "ok" && !props.breakdown
? {
name: String(
t("rum.analytics.funnel.trendPrevSeriesOf", {
range: raw(props.previous.range),
label: props.previous.label,
}),
),
buckets: props.previous.buckets,
labels: props.previous.buckets.map((b) => (b ? label(b.startUs) : "")),
}
: null,
stillConvertingName: stillLabel.value,
bandLabel,
unitNoun: props.unitNoun,
},
t,
),
);
</script>
@@ -30,6 +30,7 @@ vi.mock("@/composables/rum/useStepOptions", async (importOriginal) => {
import { resetStepOptions } from "@/utils/rum/__fixtures__/stepOptionsMock";
import FunnelLayoutRail from "./FunnelLayoutRail.vue";
import FunnelStepsTable from "../../funnelParts/FunnelStepsTable.vue";
const store = createStore({
state: { selectedOrganization: { identifier: "org1" }, timezone: "UTC", zoConfig: {} },
@@ -96,10 +97,6 @@ describe("FunnelLayoutRail", () => {
includeHidden: false,
filterFields: ["geo_info_country"],
filterValues: { field: null, status: "idle", rows: [] },
settings: [
{ id: "unit", label: "Count by" as never, value: "Sessions" as never },
{ id: "window", label: "Window" as never, value: "Same session" as never },
],
...extra,
},
global: {
@@ -107,7 +104,8 @@ describe("FunnelLayoutRail", () => {
stubs: {
VueDraggableNext: draggableStub,
OPopover: popoverStub,
ODropdown: popoverStub,
ODropdown: { ...popoverStub },
ODropdownSub: { template: '<div><slot name="trigger" /><slot /></div>' },
StepPicker: true,
},
},
@@ -140,12 +138,45 @@ describe("FunnelLayoutRail", () => {
"App filters for web",
);
expect(w.find('[data-test="rum-analytics-funnel-app-filters-none"]').exists()).toBe(true);
expect(w.find('[data-test="rum-analytics-funnel-rail-setting-window"]').text()).toContain(
"Same session",
);
expect(w.find('[data-test="rum-analytics-funnel-steps-table"]').exists()).toBe(true);
});
it("has no Settings row, since unit, window and breakdown are all on screen elsewhere", () => {
const w = mountRail();
expect(w.find('[data-test="rum-analytics-funnel-rail-settings"]').exists()).toBe(false);
expect(w.find("aside").text()).not.toContain("Settings");
});
it("forwards a step row's trend-from-step to the page", async () => {
const w = mountRail();
w.findComponent(FunnelStepsTable).vm.$emit("trend-from-step", 1);
await w.vm.$nextTick();
expect(w.emitted("trend-from-step")).toEqual([[1]]);
});
it("renders the breakdown slot as the rail's first section, above Filter all steps", () => {
const base = mountRail();
const props = base.props() as never;
base.unmount();
wrapper = mount(FunnelLayoutRail, {
props,
slots: { breakdown: "<div data-test='b' />" },
global: {
plugins: [store],
stubs: { VueDraggableNext: { template: "<div><slot /></div>" } },
},
});
const sections = wrapper.find('[data-test="rum-analytics-funnel-rail-sections"]');
const ids = sections
.findAll("[data-test]")
.map((e) => e.attributes("data-test"))
.filter((id) => ["b", "rum-analytics-funnel-filter-all"].includes(id!));
expect(ids).toEqual(["b", "rum-analytics-funnel-filter-all"]);
expect(
wrapper.find('[data-test="rum-analytics-funnel-card"]').find("[data-test='b']").exists(),
).toBe(false);
});
it("a page step's filter button opens its conditions and emits update:def", async () => {
const w = mountRail();
expect(w.find('[data-test="rum-analytics-funnel-step-0-filters-popover"]').exists()).toBe(
@@ -160,10 +191,11 @@ describe("FunnelLayoutRail", () => {
expect(def.steps[0].f).toEqual({ frustrated: true, errored: true });
});
it("a click step has no condition editor", () => {
expect(mountRail().find('[data-test="rum-analytics-funnel-step-1-filters"]').exists()).toBe(
false,
);
it("a click step's condition editor offers only its Property rows", async () => {
const w = mountRail();
await w.find('[data-test="rum-analytics-funnel-step-1-filters"]').trigger("click");
expect(w.find('[data-test="rum-analytics-funnel-step-1-property"]').exists()).toBe(true);
expect(w.find('[data-test="rum-analytics-funnel-step-1-filter-min"]').exists()).toBe(false);
});
it("table rows drag by their handle, and a drop reorders the funnel", async () => {
@@ -351,6 +383,20 @@ describe("FunnelLayoutRail", () => {
}
});
it("expand() opens a collapsed panel, and leaves an open one open", async () => {
localStorage.setItem(COLLAPSED_KEY, "1");
const w = mountRail();
const expand = () => (w.vm as unknown as { expand: () => void }).expand();
expand();
await w.vm.$nextTick();
expect(w.find(PANEL).classes()).not.toContain("lg:hidden");
expect(toggleIn(w).attributes("aria-expanded")).toBe("true");
expect(w.find(SEPARATOR).classes()).not.toContain("hidden");
expand();
await w.vm.$nextTick();
expect(toggleIn(w).attributes("aria-expanded")).toBe("true");
});
it("still collapses when localStorage cannot be written", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
@@ -423,7 +469,24 @@ describe("FunnelLayoutRail", () => {
await w.vm.$nextTick();
expect(separatorOf(w).attributes("aria-valuemax")).toBe("480");
await w.setProps({ hideTime: false });
expect(separatorOf(w).attributes("aria-valuemax")).toBe("406");
expect(separatorOf(w).attributes("aria-valuemax")).toBe("350");
});
it("gives the table its compare columns before the rail grows", async () => {
observeWidth(1318);
const w = mountRail({ hideTime: true });
await w.vm.$nextTick();
await w.setProps({
compare: {
status: "loading",
data: null,
range: "12–15 Oct",
prevRange: "8–11 Oct",
rangeExact: "",
prevRangeExact: "",
},
});
expect(separatorOf(w).attributes("aria-valuemax")).toBe("286");
});
});
});
@@ -471,7 +534,6 @@ describe("FunnelLayoutRail: exclusion steps and step options", () => {
app: "web",
appFilters: null,
includeHidden: false,
settings: [],
filterFields: ["geo_info_country"],
filterValues: { field: null, status: "idle", rows: [] },
...extra,
@@ -481,7 +543,8 @@ describe("FunnelLayoutRail: exclusion steps and step options", () => {
stubs: {
VueDraggableNext: draggableStub,
OPopover: popoverStub,
ODropdown: popoverStub,
ODropdown: { ...popoverStub },
ODropdownSub: { template: '<div><slot name="trigger" /><slot /></div>' },
StepPicker: true,
},
},
@@ -601,7 +664,7 @@ describe("FunnelLayoutRail: exclusion steps and step options", () => {
expect(w.text()).toMatch(/\b5\s?s\b/);
});
it("a page step's options offer time on page, frustrated and errored; a click step has none", async () => {
it("a page step's options offer time on page, frustrated and errored; a click step only a property", async () => {
const w = mountLayout(
defWith(
[],
@@ -615,7 +678,10 @@ describe("FunnelLayoutRail: exclusion steps and step options", () => {
const page = w.find('[data-test="rum-analytics-funnel-step-0-filters-popover"]').text();
expect(page).toMatch(/time on page/i);
expect(page).toMatch(/frustrat/i);
expect(w.find('[data-test="rum-analytics-funnel-step-1-filters"]').exists()).toBe(false);
await w.find('[data-test="rum-analytics-funnel-step-1-filters"]').trigger("click");
const click = w.find('[data-test="rum-analytics-funnel-step-1-filters-popover"]').text();
expect(click).not.toMatch(/time on page/i);
expect(click).toMatch(/property/i);
});
it("choosing the frustrated filter on a page step emits a def whose step carries it", async () => {
@@ -40,14 +40,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="flex h-full min-w-0 flex-col gap-4 lg:overflow-y-auto lg:py-3"
data-test="rum-analytics-funnel-rail-sections"
>
<slot name="breakdown" />
<FunnelFilterBar
:def="def"
:fields="filterFields"
:field-types="filterFieldTypes"
:values="filterValues"
:notice="filterNotice"
:unit-noun="unitNoun"
stacked
@update:def="(d) => emit('update:def', d)"
@filter-values="(f) => emit('filter-values', f)"
@break-down="(f) => emit('break-down', f)"
/>
<ExclusionSection
:def="def"
@@ -71,24 +75,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@edit="emit('edit-app-filters')"
/>
</div>
<FunnelSectionRow
:title="t('rum.analytics.funnel.railSettings')"
stacked
boxed
test-id="rum-analytics-funnel-rail-settings"
>
<template v-for="(s, i) in settings" :key="s.id">
<span v-if="i > 0" class="text-text-secondary" aria-hidden="true">{{
separator
}}</span>
<span
class="text-text-secondary"
:title="s.label"
:data-test="`rum-analytics-funnel-rail-setting-${s.id}`"
>{{ s.value }}</span
>
</template>
</FunnelSectionRow>
</aside>
</template>
<template #separator>
@@ -164,7 +150,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="flex flex-col gap-2">
<slot name="notices" />
<div v-if="!failed" class="flex min-w-0 flex-col" data-test="rum-analytics-funnel">
<slot v-if="trend" name="trend" />
<div
v-else-if="!failed"
class="flex min-w-0 flex-col overflow-x-auto"
data-test="rum-analytics-funnel"
>
<FunnelStepsTable
:def="def"
:result="result"
@@ -177,8 +168,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:unit-label="unitLabel"
:unit-noun="unitNoun"
:hide-time="hideTime"
:compare="compare"
:field-sample="fieldSample"
:field-sample-state="fieldSampleState"
:filter-values="filterValues"
@update:def="(d) => emit('update:def', d)"
@dropoff="(k) => emit('dropoff', k)"
@filter-values="(f) => emit('filter-values', f)"
@load-fields="emit('load-fields')"
@break-down-step="(k) => emit('break-down-step', k)"
@trend-from-step="(k) => emit('trend-from-step', k)"
/>
</div>
<slot name="footer" />
@@ -202,7 +201,6 @@ import FunnelStepsTable from "@/components/rum/productAnalytics/funnelParts/Funn
import FunnelAddStep from "@/components/rum/productAnalytics/funnelParts/FunnelAddStep.vue";
import ExclusionSection from "@/components/rum/productAnalytics/funnelParts/ExclusionSection.vue";
import FunnelFilterBar from "@/components/rum/productAnalytics/funnelParts/FunnelFilterBar.vue";
import FunnelSectionRow from "@/components/rum/productAnalytics/funnelParts/FunnelSectionRow.vue";
import AppFiltersSummary from "@/components/rum/productAnalytics/funnelParts/AppFiltersSummary.vue";
import { removedByStep } from "@/components/rum/productAnalytics/funnelParts/funnelEdits";
import type {
@@ -210,7 +208,7 @@ import type {
FunnelLayoutProps,
FunnelLayoutSlots,
} from "@/components/rum/productAnalytics/funnelLayouts/types";
import { raw, useI18nTyped } from "@/types/i18n";
import { useI18nTyped } from "@/types/i18n";
defineOptions({ name: "FunnelLayoutRail" });
@@ -218,7 +216,6 @@ const props = defineProps<FunnelLayoutProps>();
const emit = defineEmits<FunnelLayoutEmits>();
defineSlots<FunnelLayoutSlots>();
const { t } = useI18nTyped();
const separator = raw("·");
const COLLAPSED_KEY = "o2.rum.analytics.funnelRailCollapsed";
const WIDTH_KEY = "o2.rum.analytics.funnelRailWidth";
// Widths in rem, measured: below 17 the rail's switch label wraps; past 30 no chip gains anything.
@@ -226,8 +223,11 @@ const RAIL_MIN_REM = 17;
const RAIL_DEFAULT_REM = 22;
const RAIL_MAX_REM = 30;
// The table's fixed columns plus a Step column that keeps a typical step name on one or two lines.
const TABLE_MIN_REM = 55;
const TABLE_MIN_NO_TIME_REM = 46;
const TABLE_MIN_REM = 58.5;
const TABLE_MIN_NO_TIME_REM = 49.5;
// The previous-period and change columns; past the rail's minimum the table scrolls inside the card.
const TABLE_COMPARE_EXTRA_REM = 13;
const TREND_MIN_REM = 40;
const SEPARATOR_REM = 2;
const adding = ref(false);
@@ -251,6 +251,10 @@ const toggleCollapsed = () => {
}
};
const expand = () => {
if (collapsed.value) toggleCollapsed();
};
const rem = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const frame = ref<HTMLElement | null>(null);
const frameWidth = ref(0);
@@ -266,7 +270,10 @@ try {
const limits = computed<[number, number]>(() => {
const r = rem();
const min = RAIL_MIN_REM * r;
const table = (props.hideTime ? TABLE_MIN_NO_TIME_REM : TABLE_MIN_REM) * r;
const steps =
(props.hideTime ? TABLE_MIN_NO_TIME_REM : TABLE_MIN_REM) +
(props.compare ? TABLE_COMPARE_EXTRA_REM : 0);
const table = (props.trend ? TREND_MIN_REM : steps) * r;
const room = frameWidth.value ? frameWidth.value - SEPARATOR_REM * r - table : RAIL_MAX_REM * r;
return [min, Math.max(min, Math.min(RAIL_MAX_REM * r, room))];
});
@@ -304,4 +311,6 @@ watch(
if (n > was) adding.value = false;
},
);
defineExpose({ expand, collapsed: computed(() => collapsed.value) });
</script>
@@ -17,16 +17,16 @@ import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import type { NamedEventsStatus } from "@/composables/rum/useNamedEvents";
import type { AppFilterCounts } from "@/composables/rum/useAppFilterCounts";
import type { I18nText } from "@/types/i18n";
import type { FilterValuesState } from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import type {
FilterNotice,
FilterValuesState,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import type { FieldSampleState } from "@/composables/rum/useFunnelFieldSample";
import type { FieldSample } from "@/utils/rum/funnelFields";
import type { FunnelResult, NamedEvent } from "@/utils/rum/productAnalyticsModel";
import type { FunnelCompareView } from "@/utils/rum/funnelCompare";
import type { FunnelDef, SampleRatio, StepRef } from "@/utils/rum/productAnalyticsQueries";
export interface FunnelLayoutSetting {
id: string;
label: I18nText;
value: I18nText;
}
/** Everything a funnel layout renders; a layout reads these and never runs a query of its own. */
export interface FunnelLayoutProps {
def: FunnelDef;
@@ -45,11 +45,17 @@ export interface FunnelLayoutProps {
app: string;
appFilters: AppFilterCounts | null;
includeHidden: boolean;
settings: FunnelLayoutSetting[];
filterFields: readonly string[];
filterFieldTypes?: Readonly<Record<string, string>>;
filterValues: FilterValuesState;
failed?: boolean;
compare?: FunnelCompareView | null;
/** The card shows the `trend` slot in place of the steps table. */
trend?: boolean;
/** Fields each step's events carry, for the step menus and Property rows. */
fieldSample?: FieldSample | null;
fieldSampleState?: FieldSampleState;
filterNotice?: FilterNotice | null;
}
export interface FunnelLayoutEmits {
@@ -60,12 +66,18 @@ export interface FunnelLayoutEmits {
"hide-hidden": [];
"edit-app-filters": [];
"filter-values": [string];
"load-fields": [];
"break-down": [string];
"break-down-step": [number];
"trend-from-step": [number];
}
/** A layout places the page's card header, its notices and its footer, so it decides where the card sits. */
export interface FunnelLayoutSlots {
breakdown?: () => unknown;
header?: () => unknown;
actions?: () => unknown;
notices?: () => unknown;
trend?: () => unknown;
footer?: () => unknown;
}
@@ -0,0 +1,307 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import FunnelBreakdownControl from "./FunnelBreakdownControl.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { BreakdownStart } from "@/utils/rum/productAnalyticsModel";
import type { FieldSample } from "@/utils/rum/funnelFields";
import type { FunnelDef } from "@/utils/rum/productAnalyticsQueries";
const PAY = "context_payment_method";
const DEF: FunnelDef = {
steps: [
{ kind: "c", key: "product_viewed" },
{ kind: "c", key: "payment_info_submitted" },
{ kind: "c", key: "order_completed" },
],
unit: "sessions",
window: "session",
breakdown: null,
};
const SAMPLE: FieldSample = {
fields: [{ field: PAY, steps: [2, 3], top: ["card", "upi", "cod"], more: 1, many: false }],
};
const START: BreakdownStart = {
step: 2,
pinned: false,
anyStep: false,
covered: 36,
reached: 36,
auto: 2,
};
const popoverStub = {
props: ["open", "side"],
emits: ["update:open"],
template:
'<div><div @click="$emit(\'update:open\', !open)"><slot name="trigger" /></div><slot v-if="open" /></div>',
};
const dt = (id: string) => `[data-test="${id}"]`;
let wrapper: VueWrapper | null = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
document.body.innerHTML = "";
});
const mountControl = (props: Record<string, unknown> = {}) => {
wrapper = mount(FunnelBreakdownControl, {
props: {
def: DEF,
dims: [{ dim: "browser", label: "Browser" }],
fields: ["geo_info_country", "usr_id", PAY, "context_plan"],
sample: SAMPLE,
sampleState: "ok",
start: null,
stepNames: ["product_viewed", "payment_info_submitted", "order_completed"],
...props,
},
global: {
stubs: {
OPopover: popoverStub,
ODropdown: { template: '<div><slot name="trigger" /><slot /></div>' },
ODropdownItem: {
emits: ["select"],
template: "<button @click=\"$emit('select')\"><slot /></button>",
},
},
},
attachTo: document.body,
});
return wrapper;
};
const SECTION = "rum-analytics-funnel-breakdown-section";
const ADD = "rum-analytics-funnel-add-breakdown-btn";
const CHIP = "rum-analytics-funnel-breakdown-chip";
const PICKER = "rum-analytics-funnel-field-picker";
const openPicker = async (w: VueWrapper) => {
await w.find(dt(ADD)).trigger("click");
};
const openForStep = async (w: VueWrapper, k: number) => {
(w.vm as unknown as { openForStep: (k: number) => void }).openForStep(k);
await w.vm.$nextTick();
};
const row = (w: VueWrapper, id: string) => w.find(dt(`rum-analytics-funnel-field-picker-${id}`));
const lastDef = (w: VueWrapper) => (w.emitted("update:def") as [FunnelDef][]).at(-1)![0];
const removeBtn = (w: VueWrapper) => w.find(dt(CHIP)).find('button[aria-label="Remove breakdown"]');
describe("FunnelBreakdownControl, the rail's Breakdown section", () => {
it("is a titled section with a caption and a +, with no chip and no toolbar select", () => {
const w = mountControl();
const section = w.find(dt(SECTION));
expect(section.exists()).toBe(true);
expect(section.text()).toContain("Breakdown");
expect(
section
.find('[aria-label="Split every step by one field, such as browser or payment method"]')
.exists(),
).toBe(true);
expect(section.find(dt(ADD)).exists()).toBe(true);
expect(w.find(dt("rum-analytics-funnel-breakdown-select")).exists()).toBe(false);
expect(w.find(dt(CHIP)).exists()).toBe(false);
});
it("the + opens the picker beside the rail and asks for the step fields", async () => {
const w = mountControl();
await openPicker(w);
expect(w.find(dt(PICKER)).exists()).toBe(true);
expect(w.emitted("load-fields")).toHaveLength(1);
expect(w.findComponent(popoverStub).props("side")).toBe("right");
});
it("bands fields: on these steps with step and values, key fields, user disabled, and all fields", async () => {
const w = mountControl();
await openPicker(w);
const text = w.find(dt(PICKER)).text();
expect(text).toContain("On these steps (1)");
expect(text).toContain("Key fields (1)");
expect(text).toContain("User (1)");
expect(text).toContain("All fields (1)");
expect(row(w, `field-${PAY}`).text()).toContain("Step 2 · card, upi, cod +1");
expect(row(w, "field-context_plan").exists()).toBe(true);
expect(row(w, "field-geo_info_country").exists()).toBe(false);
});
it("search narrows the list; picking an attribute writes an automatic breakdown and closes", async () => {
const w = mountControl();
await openPicker(w);
await w.find(`${dt("o-field-list-search")} input`).setValue("pay");
expect(row(w, "dim-browser").exists()).toBe(false);
await row(w, `field-${PAY}`).trigger("click");
expect(lastDef(w).breakdown).toEqual({ f: PAY });
expect(w.find(dt(PICKER)).exists()).toBe(false);
});
it("picking a key field keeps today's built-in breakdown", async () => {
const w = mountControl();
await openPicker(w);
await row(w, "dim-browser").trigger("click");
expect(lastDef(w).breakdown).toBe("browser");
});
it("a user field is shown but cannot be picked", async () => {
const w = mountControl();
await openPicker(w);
await row(w, "field-usr_id").trigger("click");
expect(w.emitted("update:def")).toBeUndefined();
});
it("the focusable row of a field with a reason carries aria-disabled", async () => {
const w = mountControl();
await openPicker(w);
const button = (id: string) => row(w, id).element.closest('[role="button"]');
expect(button("field-usr_id")?.getAttribute("aria-disabled")).toBe("true");
expect(button(`field-${PAY}`)?.hasAttribute("aria-disabled")).toBe(false);
});
it("a set breakdown is one chip; the + is greyed with One breakdown at a time", async () => {
const w = mountControl({ def: { ...DEF, breakdown: "browser" } });
expect(w.find(dt(CHIP)).text()).toContain("Browser");
const add = w.find(dt(ADD));
expect(add.attributes("disabled")).toBeDefined();
expect(
w.findAllComponents(OTooltip).some((c) => c.props("content") === "One breakdown at a time"),
).toBe(true);
await add.trigger("click");
expect(w.find(dt(PICKER)).exists()).toBe(false);
});
it("clicking the chip reopens the picker to change the field", async () => {
const w = mountControl({ def: { ...DEF, breakdown: "browser" } });
await w.find(dt(CHIP)).trigger("click");
expect(w.find(dt(PICKER)).exists()).toBe(true);
expect(w.emitted("load-fields")).toHaveLength(1);
await row(w, `field-${PAY}`).trigger("click");
expect(lastDef(w).breakdown).toEqual({ f: PAY });
});
it("keeps keyboard focus in the section after a pick and after a remove", async () => {
const w: VueWrapper = mountControl({
"onUpdate:def": (d: FunnelDef) => w.setProps({ def: d }),
});
await openPicker(w);
await row(w, "dim-browser").trigger("click");
await flushPromises();
expect(document.activeElement).toBe(w.find(dt(CHIP)).element);
await removeBtn(w).trigger("click");
await flushPromises();
expect(document.activeElement).toBe(w.find(dt(ADD)).element);
});
it("focus() shows the focus ring on the chip, or on the + when there is no breakdown", async () => {
const focus = vi.spyOn(HTMLElement.prototype, "focus");
try {
const w = mountControl({ def: { ...DEF, breakdown: "browser" } });
(w.vm as unknown as { focus: () => void }).focus();
expect(document.activeElement).toBe(w.find(dt(CHIP)).element);
expect(focus.mock.contexts.at(-1)).toBe(w.find(dt(CHIP)).element);
expect(focus.mock.calls.at(-1)).toEqual([{ focusVisible: true }]);
await w.setProps({ def: DEF });
(w.vm as unknown as { focus: () => void }).focus();
expect(focus.mock.contexts.at(-1)).toBe(w.find(dt(ADD)).element);
expect(focus.mock.calls.at(-1)).toEqual([{ focusVisible: true }]);
} finally {
focus.mockRestore();
}
});
it("the chip's remove control clears the breakdown", async () => {
const w = mountControl({ def: { ...DEF, breakdown: "browser" } });
expect(removeBtn(w).exists()).toBe(true);
await removeBtn(w).trigger("click");
expect(lastDef(w).breakdown).toBeNull();
expect(w.find(dt(PICKER)).exists()).toBe(false);
});
it("a field breakdown shows Value from under the chip, writing bs or going back to automatic", async () => {
const w = mountControl({ def: { ...DEF, breakdown: { f: PAY } }, start: START });
expect(w.find(dt(CHIP)).text()).toContain(PAY);
const from = w.find(dt("rum-analytics-funnel-breakdown-from-step"));
expect(from.text()).toBe("Automatic (step 2)");
expect(
w.find(dt(CHIP)).element.compareDocumentPosition(from.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(w.find(dt("rum-analytics-funnel-breakdown-from-auto")).text()).toBe(
"Automatic (step 2)",
);
expect(w.find(dt("rum-analytics-funnel-breakdown-from-3")).text()).toBe(
"Step 3 · order_completed",
);
await w.find(dt("rum-analytics-funnel-breakdown-from-3")).trigger("click");
expect(lastDef(w).breakdown).toEqual({ f: PAY, s: 3 });
await w.setProps({
def: { ...DEF, breakdown: { f: PAY, s: 3 } },
start: { ...START, step: 3, pinned: true },
});
expect(w.find(dt("rum-analytics-funnel-breakdown-from-step")).text()).toBe("From step 3");
await w.find(dt("rum-analytics-funnel-breakdown-from-auto")).trigger("click");
expect(lastDef(w).breakdown).toEqual({ f: PAY });
});
it("without a start, Value from names a pinned step or reads Automatic", () => {
const w = mountControl({ def: { ...DEF, breakdown: { f: PAY, s: 3 } } });
expect(w.find(dt("rum-analytics-funnel-breakdown-from-step")).text()).toBe("From step 3");
wrapper!.unmount();
const a = mountControl({ def: { ...DEF, breakdown: { f: PAY } } });
expect(a.find(dt("rum-analytics-funnel-breakdown-from-step")).text()).toBe("Automatic");
});
it("from any step names the share that carries a value", () => {
const w = mountControl({
def: { ...DEF, breakdown: { f: PAY } },
start: { step: 1, pinned: false, anyStep: true, covered: 12, reached: 100, auto: null },
});
expect(w.find(dt("rum-analytics-funnel-breakdown-from-step")).text()).toBe(
"From any step · 12.0% set",
);
});
it("a key-field breakdown has no Value from", () => {
const w = mountControl({ def: { ...DEF, breakdown: "browser" } });
expect(w.find(dt("rum-analytics-funnel-breakdown-from-step")).exists()).toBe(false);
});
it("opened for a step, lists only that step's fields and pins the pick to it", async () => {
const w = mountControl();
await openForStep(w, 3);
const text = w.find(dt(PICKER)).text();
expect(text).toContain("On step 3 (1)");
expect(text).not.toContain("Key fields");
expect(row(w, `field-${PAY}`).text()).toContain("card, upi, cod +1");
await row(w, `field-${PAY}`).trigger("click");
expect(lastDef(w).breakdown).toEqual({ f: PAY, s: 3 });
await openPicker(w);
expect(w.find(dt(PICKER)).text()).toContain("Key fields");
});
it("opened for a step the sample saw nothing on, says so", async () => {
const w = mountControl();
await openForStep(w, 1);
expect(w.find(dt("rum-analytics-funnel-field-picker-note")).text()).toBe(
"No fields seen on this step",
);
});
it("says while the step fields are still being read", async () => {
const w = mountControl({ sample: null, sampleState: "loading" });
await openPicker(w);
expect(w.find(dt("rum-analytics-funnel-field-picker-note")).text()).toBe(
"Reading the fields on this funnel's steps…",
);
});
});
@@ -0,0 +1,320 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<FunnelSectionRow
:title="t('rum.analytics.funnel.railBreakdown')"
:caption="t('rum.analytics.funnel.breakdownCaption')"
stacked
:boxed="!!def.breakdown"
test-id="rum-analytics-funnel-breakdown-section"
>
<OTag
v-if="def.breakdown"
ref="chip"
:label="valueLabel"
variant="primary-outline"
size="sm"
clickable
removable
class="max-w-full min-w-0"
:remove-label="t('rum.analytics.funnel.breakdownRemove')"
data-test="rum-analytics-funnel-breakdown-chip"
@click="setOpen(true)"
@remove="remove"
>
<OTruncatedText as="span" class="min-w-0" :class="attribute ? 'font-mono' : ''">{{
valueLabel
}}</OTruncatedText>
</OTag>
<template v-if="attribute" #trailing>
<span class="text-text-secondary text-xs">{{
t("rum.analytics.funnel.breakdownValueFrom")
}}</span>
<ODropdown align="start">
<template #trigger>
<OButton
variant="outline"
size="xs"
icon-right="arrow-drop-down"
data-test="rum-analytics-funnel-breakdown-from-step"
>{{ fromLabel }}</OButton
>
</template>
<ODropdownItem
:icon-left="attribute.s === undefined ? 'check' : undefined"
data-test="rum-analytics-funnel-breakdown-from-auto"
@select="setStep(null)"
>{{
start?.auto
? t("rum.analytics.funnel.fromAutomatic", { step: start.auto })
: t("rum.analytics.funnel.fromAutomaticNone")
}}</ODropdownItem
>
<ODropdownItem
v-for="(name, i) in stepNames"
:key="i"
:icon-left="attribute.s === i + 1 ? 'check' : undefined"
:data-test="`rum-analytics-funnel-breakdown-from-${i + 1}`"
@select="setStep(i + 1)"
>{{
t("rum.analytics.funnel.fromStepItem", { step: i + 1, name: raw(name) })
}}</ODropdownItem
>
</ODropdown>
</template>
<template #action>
<OPopover
:open="open"
side="right"
align="start"
content-class="p-3 w-96 max-w-[calc(100vw-1.5rem)]"
:aria-label="t('rum.analytics.funnel.railBreakdown')"
@update:open="setOpen"
>
<template #trigger>
<span>
<OButton
ref="add"
variant="ghost"
size="icon-sm"
icon-left="add"
:active="open"
:disabled="!!def.breakdown"
:aria-label="t('rum.analytics.funnel.breakdownAdd')"
data-test="rum-analytics-funnel-add-breakdown-btn"
/>
<OTooltip
:content="
def.breakdown
? t('rum.analytics.funnel.breakdownOneAtATime')
: t('rum.analytics.funnel.breakdownAdd')
"
/>
</span>
</template>
<SessionFieldPicker
v-if="open"
:fields="[]"
:values="idleValues"
:groups="groups"
:note="note"
@pick="pick"
@cancel="setOpen(false)"
/>
</OPopover>
</template>
</FunnelSectionRow>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import FunnelSectionRow from "@/components/rum/productAnalytics/funnelParts/FunnelSectionRow.vue";
import SessionFieldPicker from "@/components/rum/productAnalytics/funnelParts/SessionFieldPicker.vue";
import type {
FilterValuesState,
PickerField,
PickerGroup,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import type { FieldSampleState } from "@/composables/rum/useFunnelFieldSample";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { fieldsOnStep, type FieldSample, type SeenField } from "@/utils/rum/funnelFields";
import { formatPct, isFilterColumn, type BreakdownStart } from "@/utils/rum/productAnalyticsModel";
import {
isAttributeBreakdown,
type BreakdownDim,
type FunnelDef,
} from "@/utils/rum/productAnalyticsQueries";
defineOptions({ name: "FunnelBreakdownControl" });
const props = defineProps<{
def: FunnelDef;
/** The built-in breakdowns this app's data has, with their names. */
dims: readonly { dim: BreakdownDim; label: I18nText }[];
/** Every field Filter all steps offers; attribute columns among them can break a funnel down. */
fields: readonly string[];
sample: FieldSample | null;
sampleState: FieldSampleState;
start: BreakdownStart | null;
stepNames: readonly string[];
}>();
const emit = defineEmits<{
"update:def": [FunnelDef];
"load-fields": [];
}>();
const { t } = useI18nTyped();
const open = ref(false);
const chip = ref<{ $el: HTMLElement } | null>(null);
const add = ref<{ $el: HTMLElement } | null>(null);
// Set while the picker was opened from a step's menu: it lists that step's fields and pins the pick to it.
const limitStep = ref<number | null>(null);
const idleValues: FilterValuesState = { field: null, status: "idle", rows: [] };
const DIM_ID = "dim-";
const FIELD_ID = "field-";
const attribute = computed(() =>
isAttributeBreakdown(props.def.breakdown) ? props.def.breakdown : null,
);
const valueLabel = computed<I18nText>(() => {
const b = props.def.breakdown;
if (!b) return raw("");
if (isAttributeBreakdown(b)) return raw(b.f);
return props.dims.find((d) => d.dim === b)?.label ?? raw(b);
});
const fromLabel = computed<I18nText>(() => {
const s = props.start;
if (s?.anyStep)
return t("rum.analytics.funnel.fromAnyStep", {
pct: raw(formatPct(s.reached > 0 ? s.covered / s.reached : 0)),
});
if (attribute.value?.s === undefined && s?.auto)
return t("rum.analytics.funnel.fromAutomatic", { step: s.auto });
const step = s?.step ?? attribute.value?.s;
return step
? t("rum.analytics.funnel.fromStep", { step })
: t("rum.analytics.funnel.fromAutomaticNone");
});
const seenHint = (f: SeenField): I18nText => {
const step = f.steps[0];
if (f.many) return t("rum.analytics.funnel.pickerManyValues", { step });
const values = raw(f.top.join(", "));
return f.more
? t("rum.analytics.funnel.pickerStepHintMore", { step, values, more: f.more })
: t("rum.analytics.funnel.pickerStepHint", { step, values });
};
const isUser = (f: string) => f.startsWith("usr_");
const stepHint = (f: SeenField): I18nText => {
if (f.many) return t("rum.analytics.funnel.stepFieldMany");
const values = raw(f.top.join(", "));
return f.more ? t("rum.analytics.funnel.stepFieldValues", { values, more: f.more }) : values;
};
const groups = computed<PickerGroup[]>(() => {
const k = limitStep.value;
if (k !== null)
return [
{
id: "step",
label: t("rum.analytics.funnel.pickerOnStep", { step: k }),
fields: fieldsOnStep(props.sample, k).map((f) => ({
id: `${FIELD_ID}${f.field}`,
name: f.field,
hint: stepHint(f),
})),
},
];
const seen = props.sample?.fields ?? [];
const onSteps = new Set(seen.map((f) => f.field));
const fieldRow = (field: string, extra: Partial<PickerField> = {}): PickerField => ({
id: `${FIELD_ID}${field}`,
name: field,
...extra,
});
return [
{
id: "steps",
label: t("rum.analytics.funnel.pickerOnSteps"),
fields: seen.map((f) => fieldRow(f.field, { hint: seenHint(f) })),
},
{
id: "key",
label: t("rum.analytics.funnel.pickerKeyFields"),
fields: props.dims.map((d) => ({ id: `${DIM_ID}${d.dim}`, name: d.label })),
},
{
id: "user",
label: t("rum.analytics.funnel.pickerUser"),
fields: props.fields
.filter(isUser)
.map((f) => fieldRow(f, { reason: t("rum.analytics.funnel.pickerUserReason") })),
},
{
id: "all",
label: t("rum.analytics.funnel.pickerAllFields"),
fields: props.fields
.filter((f) => isFilterColumn(f) && !isUser(f) && !onSteps.has(f))
.map((f) => fieldRow(f)),
},
];
});
const note = computed<I18nText | null>(() => {
if (props.sampleState === "loading") return t("rum.analytics.funnel.pickerSampling");
if (props.sampleState === "error") return t("rum.analytics.funnel.pickerSampleFailed");
if (limitStep.value !== null && !groups.value[0].fields.length)
return t("rum.analytics.funnel.propertyNoFields");
return null;
});
const setOpen = (v: boolean) => {
open.value = v;
if (v) emit("load-fields");
else limitStep.value = null;
};
const openForStep = (step: number) => {
limitStep.value = step;
setOpen(true);
};
// TS's DOM lib lacks focusVisible; it shows the ring even when a mouse click led here.
const FOCUS_RING: FocusOptions & { focusVisible: boolean } = { focusVisible: true };
const focus = () => (chip.value ?? add.value)?.$el.focus(FOCUS_RING);
// The picker's trigger turns disabled and the chip unmounts, which would drop keyboard focus to the page.
const refocus = () => void nextTick(focus);
const pick = (id: string) => {
const k = limitStep.value;
setOpen(false);
if (id.startsWith(DIM_ID)) {
emit("update:def", { ...props.def, breakdown: id.slice(DIM_ID.length) as BreakdownDim });
refocus();
return;
}
const f = id.slice(FIELD_ID.length);
emit("update:def", { ...props.def, breakdown: k === null ? { f } : { f, s: k } });
refocus();
};
const remove = () => {
setOpen(false);
emit("update:def", { ...props.def, breakdown: null });
refocus();
};
const setStep = (s: number | null) => {
const a = attribute.value;
if (!a) return;
emit("update:def", { ...props.def, breakdown: s === null ? { f: a.f } : { f: a.f, s } });
};
defineExpose({ openForStep, focus });
</script>
@@ -0,0 +1,83 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<span
v-if="change"
class="inline-flex flex-wrap items-baseline gap-x-1 tabular-nums"
:class="end ? 'justify-end text-end' : ''"
:data-tone="change.lowVolume ? 'low' : direction"
>
<span :class="toneClass">{{ label }}</span>
<span
v-if="change.lowVolume"
class="text-text-secondary text-xs"
data-test="rum-analytics-funnel-low-volume"
>{{ t("rum.analytics.funnel.lowVolume") }}</span
>
<OTooltip :content="tip" />
</span>
<span v-else class="text-text-secondary tabular-nums" :class="end ? 'text-end' : ''"
>{{ dash }}<OTooltip v-if="failed" :content="t('rum.analytics.funnel.previousFailed')"
/></span>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { addCommasToNumber } from "@/utils/formatters";
import { changeDirection, formatPts, type RateChange } from "@/utils/rum/funnelCompare";
const props = defineProps<{
change: RateChange | null;
unitNoun: I18nText;
failed?: boolean;
end?: boolean;
}>();
const { t } = useI18nTyped();
const dash = raw("—");
const ARROWS = { up: "▲ ", down: "▼ ", flat: "" } as const;
const direction = computed(() => (props.change ? changeDirection(props.change) : "flat"));
const label = computed<I18nText>(() =>
props.change
? t("rum.analytics.funnel.changePts", {
arrow: raw(ARROWS[direction.value]),
value: raw(formatPts(props.change.pts)),
})
: dash,
);
// Grey is the low-volume signal, so it outranks the direction's colour.
const toneClass = computed(() => {
if (!props.change || props.change.lowVolume) return "text-text-secondary";
if (direction.value === "up") return "text-status-success-text";
return direction.value === "down" ? "text-status-error-text" : "text-text-body";
});
const tip = computed<I18nText>(() =>
props.change
? t("rum.analytics.funnel.changeTooltip", {
ci: raw(formatPts(props.change.ci95)),
cur: addCommasToNumber(props.change.curN),
prev: addCommasToNumber(props.change.prevN),
unit: props.unitNoun,
})
: dash,
);
</script>
@@ -46,6 +46,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTruncatedText as="span" class="min-w-0">{{ chipLabel(r) }}</OTruncatedText>
</OTag>
</span>
<template v-if="notice" #body>
<OBanner
variant="warning"
dense
:content="
t('rum.analytics.funnel.filterNotice', {
kept: raw(addCommasToNumber(notice.kept)),
opened: raw(addCommasToNumber(notice.opened)),
unit: unit,
})
"
data-test="rum-analytics-funnel-filter-notice"
>
<template #actions>
<OButton
variant="ghost"
size="xs"
data-test="rum-analytics-funnel-filter-notice-break-down"
@click="emit('break-down', notice.field)"
>{{
t(
def.breakdown
? "rum.analytics.funnel.breakdownReplace"
: "rum.analytics.funnel.filterNoticeAction",
{ field: raw(notice.field) },
)
}}</OButton
>
</template>
</OBanner>
<span>
<OPopover
align="start"
content-class="p-3 w-80 max-w-[calc(100vw-1.5rem)]"
:aria-label="t('rum.analytics.funnel.filterNoticeWhy')"
>
<template #trigger>
<OButton
variant="ghost-primary"
size="xs"
icon-left="info-outline"
data-test="rum-analytics-funnel-filter-notice-why"
>{{ t("rum.analytics.funnel.filterNoticeWhy") }}</OButton
>
</template>
<p class="text-text-body text-sm" data-test="rum-analytics-funnel-filter-notice-reason">
{{
t("rum.analytics.funnel.filterNoticeReason", {
unit: unit,
missing: raw(addCommasToNumber(Math.max(0, notice.opened - notice.carry))),
opened: raw(addCommasToNumber(notice.opened)),
field: raw(notice.field),
})
}}
</p>
</OPopover>
</span>
</template>
<template #action>
<OPopover
:open="open"
@@ -107,6 +165,7 @@ import OTag from "@/lib/core/Badge/OTag.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import FunnelSectionRow from "@/components/rum/productAnalytics/funnelParts/FunnelSectionRow.vue";
import SessionFieldPicker from "@/components/rum/productAnalytics/funnelParts/SessionFieldPicker.vue";
@@ -116,9 +175,11 @@ import {
filterOn,
removeFilter,
setFilter,
type FilterNotice,
type FilterValuesState,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { addCommasToNumber } from "@/utils/formatters";
import { MAX_FUNNEL_FILTERS } from "@/utils/rum/productAnalyticsModel";
import type { FunnelDef, FunnelFilter } from "@/utils/rum/productAnalyticsQueries";
@@ -130,14 +191,19 @@ const props = defineProps<{
fieldTypes?: Readonly<Record<string, string>>;
values: FilterValuesState;
stacked?: boolean;
/** Shown while an attribute filter keeps fewer than half of step 1's sessions. */
notice?: FilterNotice | null;
unitNoun?: I18nText;
}>();
const emit = defineEmits<{
"update:def": [FunnelDef];
"filter-values": [string];
"break-down": [string];
}>();
const { t } = useI18nTyped();
const open = ref(false);
const unit = computed(() => props.unitNoun ?? t("rum.analytics.funnel.sessionNoun"));
const editing = ref<string | null>(null);
// One filter per field, so a field already filtered is edited from its chip, not picked again.
@@ -16,6 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
v-if="!compare"
class="bg-progress-bar-track relative flex h-5 overflow-hidden rounded-full"
role="progressbar"
:aria-valuenow="Math.round(kept * 100)"
@@ -31,12 +32,44 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="rum-analytics-funnel-bar-removed"
/>
</div>
<div v-else class="flex flex-col gap-1">
<div
class="bg-progress-bar-track relative flex h-3 overflow-hidden rounded-full"
role="progressbar"
:aria-valuenow="Math.round(kept * 100)"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="bg-progress-bar-default h-full rounded-full" :style="{ width: pct(kept) }" />
<div
v-if="hatched > 0"
class="bg-hatch h-full"
:style="{ width: pct(hatched) }"
data-test="rum-analytics-funnel-bar-removed"
/>
</div>
<div
class="bg-progress-bar-track flex h-2 overflow-hidden rounded-full"
data-test="rum-analytics-funnel-bar-previous"
>
<div
v-if="previous !== null && previous !== undefined"
class="bg-hatch-period h-full rounded-full"
:style="{ width: pct(clamp01(previous)) }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ value: number; removed: number }>();
const props = defineProps<{
value: number;
removed: number;
compare?: boolean;
previous?: number | null;
}>();
const clamp01 = (n: number) => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0));
const kept = computed(() => clamp01(props.value));
@@ -15,27 +15,50 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="flex flex-col" data-test="rum-analytics-funnel-steps-table">
<div
class="flex flex-col"
:class="compare ? (hideTime ? 'md:min-w-[60rem]' : 'md:min-w-[69rem]') : ''"
data-test="rum-analytics-funnel-steps-table"
>
<!-- Two rows in both states, so turning compare on or off never moves the table. -->
<div
class="text-text-secondary bg-surface-subtle grid items-center gap-x-3 px-2 py-2 text-xs max-md:hidden"
:class="gridClass"
class="text-text-secondary bg-surface-subtle flex flex-col gap-1 px-2 py-2 text-xs max-md:hidden"
data-test="rum-analytics-funnel-steps-header"
>
<span />
<span
v-for="col in headerCols"
:key="col.id"
class="flex items-center gap-1"
:class="col.id === 'step' ? '' : 'justify-end'"
:data-test="`rum-analytics-funnel-col-${col.id}`"
>{{ col.label }}
<span tabindex="0" role="img" :aria-label="col.tip" class="flex items-center">
<OIcon name="info-outline" size="xs" />
<OTooltip :content="col.tip" />
<div class="grid h-5 items-end gap-x-3" :class="gridClass">
<span
v-if="compare"
class="border-border-default col-span-3 col-start-4 flex items-center justify-center gap-1 border-b pb-0.5"
data-test="rum-analytics-funnel-col-of-previous"
>{{ t("rum.analytics.funnel.ofPrevious") }}
<span
tabindex="0"
role="img"
:aria-label="t('rum.analytics.funnel.ofPreviousTooltip')"
class="flex items-center"
>
<OIcon name="info-outline" size="xs" />
<OTooltip :content="t('rum.analytics.funnel.ofPreviousTooltip')" />
</span>
</span>
</span>
<span />
<span />
</div>
<div class="grid min-h-8 items-end gap-x-3" :class="gridClass">
<span />
<span
v-for="col in headerCols"
:key="col.id"
class="flex items-center gap-1"
:class="col.id === 'step' ? '' : 'justify-end text-end'"
:data-test="`rum-analytics-funnel-col-${col.id}`"
>{{ col.label }}
<span tabindex="0" role="img" :aria-label="col.tip" class="flex items-center">
<OIcon name="info-outline" size="xs" />
<OTooltip :content="col.tip" />
</span>
</span>
<span />
<span class="bg-surface-subtle self-stretch md:sticky md:end-0" />
</div>
</div>
<VueDraggableNext
:model-value="def.steps"
@@ -104,7 +127,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
</span>
<template v-if="numbersReady && result && result.steps[i]">
<span v-if="compare" class="flex flex-col items-end tabular-nums">
<span
class="text-text-heading font-semibold"
:data-test="`rum-analytics-funnel-step-${i}-count`"
>{{ formatCount(result.steps[i].units, sampled) }}</span
>
<span
v-if="stepCompare(i)?.prevUnits != null"
class="text-text-secondary text-xs"
:data-test="`rum-analytics-funnel-step-${i}-prev-count`"
>{{
t("rum.analytics.funnel.previousCount", {
count: formatCount(stepCompare(i)?.prevUnits ?? 0, sampled),
})
}}</span
>
</span>
<span
v-else
class="text-text-heading text-end font-semibold tabular-nums"
:data-test="`rum-analytics-funnel-step-${i}-count`"
>{{ formatCount(result.steps[i].units, sampled) }}</span
@@ -112,6 +153,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="text-end tabular-nums">{{
i === 0 ? dash : pct(result.steps[i].ofPrevious)
}}</span>
<template v-if="compare">
<span
class="text-end tabular-nums"
:class="prevRate(i) === null ? 'text-text-secondary' : 'text-text-body'"
:data-test="`rum-analytics-funnel-step-${i}-prev-rate`"
>{{ prevRate(i) === null ? dash : pct(prevRate(i) ?? 0)
}}<OTooltip
v-if="compareFailed && i > 0"
:content="t('rum.analytics.funnel.previousFailed')"
/></span>
<span class="flex justify-end" :data-test="`rum-analytics-funnel-step-${i}-change`">
<FunnelChangeCell
:change="stepCompare(i)?.change ?? null"
:unit-noun="unitNoun"
:failed="compareFailed && i > 0"
end
/>
</span>
</template>
<span
v-if="!hideTime"
class="flex flex-col items-end tabular-nums"
@@ -127,18 +187,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<span v-else>{{ dash }}</span>
</span>
<span class="text-end tabular-nums">{{ pct(result.steps[i].ofFirst) }}</span>
<span v-if="prevBars" class="flex flex-col items-end tabular-nums">
<span>{{ pct(result.steps[i].ofFirst) }}</span>
<span
class="text-text-secondary text-xs"
:data-test="`rum-analytics-funnel-step-${i}-prev-of-first`"
>{{
stepCompare(i)?.prevOfFirst == null ? dash : pct(stepCompare(i)?.prevOfFirst ?? 0)
}}</span
>
</span>
<span v-else class="text-end tabular-nums">{{ pct(result.steps[i].ofFirst) }}</span>
<span class="min-w-24">
<FunnelStepBar
:value="result.steps[i].ofFirst"
:removed="removedShare(i)"
:compare="prevBars"
:previous="stepCompare(i)?.prevOfFirst ?? null"
:data-test="`rum-analytics-funnel-bar-${i}`"
/>
</span>
</template>
<template v-else-if="numbersHeld">
<span
v-for="n in hideTime ? 4 : 5"
v-for="n in numberCols"
:key="n"
class="text-text-secondary text-end"
:data-test="n === 1 ? `rum-analytics-funnel-step-${i}-held` : undefined"
@@ -146,15 +218,44 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
</template>
<template v-else>
<OSkeleton v-for="n in hideTime ? 4 : 5" :key="n" type="text" class="h-5 w-full" />
<OSkeleton v-for="n in numberCols" :key="n" type="text" class="h-5 w-full" />
</template>
<span class="flex items-center justify-end gap-0.5">
<span class="md:bg-surface-base flex items-center justify-end gap-0.5 md:sticky md:end-0">
<StepConditionPopover
v-if="step.kind === 'p'"
:step="step"
:index="i"
:fields="stepFields(i).map((f) => f.field)"
:fields-state="fieldSampleState"
:values="filterValues"
@update="(s) => replaceStep(i, s)"
@filter-values="(f) => emit('filter-values', f)"
@load-fields="emit('load-fields')"
/>
<ODropdown align="end" @update:open="(o) => o && emit('load-fields')">
<template #trigger>
<OButton
variant="ghost"
size="icon-sm"
icon-left="more-vert"
:aria-label="t('rum.analytics.funnel.stepMenu')"
:data-test="`rum-analytics-funnel-step-${i}-menu`"
><OTooltip :content="t('rum.analytics.funnel.stepMenu')"
/></OButton>
</template>
<ODropdownItem
icon-left="alt-route"
:data-test="`rum-analytics-funnel-step-${i}-break-down`"
@select="emit('break-down-step', i + 1)"
>{{ t("rum.analytics.funnel.breakDownByStep") }}</ODropdownItem
>
<ODropdownItem
v-if="i < def.steps.length - 1"
icon-left="show-chart"
:data-test="`rum-analytics-funnel-step-${i}-trend-from`"
@select="emit('trend-from-step', i + 1)"
>{{ t("rum.analytics.funnel.trendFromHere") }}</ODropdownItem
>
</ODropdown>
<OButton
variant="ghost"
size="icon-sm"
@@ -171,7 +272,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="ps-12 pb-1"
>
<OButton
v-if="result.steps[i].dropoff > 0"
v-if="result.steps[i].dropoff > 0 && dropCompare(i)"
:variant="dropVariant(i)"
size="xs"
icon-left="arrow-downward"
icon-right="chevron-right"
aria-haspopup="dialog"
:aria-label="
t('rum.analytics.funnel.droppedCompareAria', { ...dropParams(i), step: i + 1 })
"
:data-test="`rum-analytics-funnel-dropoff-${i}`"
@click="emit('dropoff', i + 1)"
>{{ t("rum.analytics.funnel.droppedCompare", dropParams(i))
}}<OTooltip
:content="
t('rum.analytics.funnel.droppedCompareTooltip', {
...dropParams(i),
outOf: formatCount(result.steps[i].units, sampled),
prevOutOf: formatCount(dropCompare(i)?.prevOutOf ?? 0, sampled),
unit: unitNoun,
range: raw(compare?.rangeExact ?? ''),
prevRange: raw(compare?.prevRangeExact ?? ''),
})
"
/></OButton>
<OButton
v-else-if="result.steps[i].dropoff > 0"
variant="ghost-primary"
size="xs"
icon-left="arrow-downward"
@@ -215,7 +341,10 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import FunnelStepBar from "@/components/rum/productAnalytics/funnelParts/FunnelStepBar.vue";
import FunnelChangeCell from "@/components/rum/productAnalytics/funnelParts/FunnelChangeCell.vue";
import StepConditionPopover from "@/components/rum/productAnalytics/funnelParts/StepConditionPopover.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import useStepLabels from "@/components/rum/productAnalytics/funnelParts/useStepLabels";
import {
removeStep,
@@ -223,7 +352,10 @@ import {
stepOrder,
type FunnelEdit,
} from "@/components/rum/productAnalytics/funnelParts/funnelEdits";
import type { FilterValuesState } from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import type { FieldSampleState } from "@/composables/rum/useFunnelFieldSample";
import { fieldsOnStep, type FieldSample, type SeenField } from "@/utils/rum/funnelFields";
import type { NamedEventsStatus } from "@/composables/rum/useNamedEvents";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import {
@@ -233,6 +365,7 @@ import {
type NamedEvent,
} from "@/utils/rum/productAnalyticsModel";
import type { FunnelDef, SampleRatio, StepRef } from "@/utils/rum/productAnalyticsQueries";
import type { DropCompare, FunnelCompareView, StepCompare } from "@/utils/rum/funnelCompare";
const props = defineProps<{
def: FunnelDef;
@@ -246,10 +379,18 @@ const props = defineProps<{
unitLabel: I18nText;
unitNoun: I18nText;
hideTime: boolean;
compare?: FunnelCompareView | null;
fieldSample?: FieldSample | null;
fieldSampleState?: FieldSampleState;
filterValues?: FilterValuesState;
}>();
const emit = defineEmits<{
"update:def": [FunnelDef];
dropoff: [number];
"filter-values": [string];
"load-fields": [];
"break-down-step": [number];
"trend-from-step": [number];
}>();
const { t } = useI18nTyped();
const { labelOf, kindLabel, kindVariant, filterChips } = useStepLabels(
@@ -259,11 +400,20 @@ const { labelOf, kindLabel, kindVariant, filterChips } = useStepLabels(
);
const dash = raw("—");
const gridClass = computed(() =>
props.hideTime
? "grid-cols-[3rem_minmax(0,3fr)_5.5rem_7rem_5.5rem_minmax(6rem,2fr)_4rem]"
: "grid-cols-[3rem_minmax(0,3fr)_5.5rem_7rem_8rem_5.5rem_minmax(6rem,2fr)_4rem]",
);
const GRID = {
plain: "grid-cols-[3rem_minmax(0,3fr)_5.5rem_7rem_7rem_minmax(6rem,2fr)_6rem]",
plainTime: "grid-cols-[3rem_minmax(0,3fr)_5.5rem_7rem_8rem_7rem_minmax(6rem,2fr)_6rem]",
compare: "grid-cols-[3rem_minmax(0,3fr)_5.5rem_6rem_6rem_6.5rem_7rem_minmax(6rem,2fr)_6rem]",
compareTime:
"grid-cols-[3rem_minmax(0,3fr)_5.5rem_6rem_6rem_6.5rem_8rem_7rem_minmax(6rem,2fr)_6rem]",
} as const;
const gridClass = computed(() => {
if (props.compare) return props.hideTime ? GRID.compare : GRID.compareTime;
return props.hideTime ? GRID.plain : GRID.plainTime;
});
const numberCols = computed(() => (props.hideTime ? 4 : 5) + (props.compare ? 2 : 0));
const headerCols = computed(() => [
{ id: "step", label: t("rum.analytics.funnel.step"), tip: t("rum.analytics.funnel.orderLabel") },
@@ -272,11 +422,31 @@ const headerCols = computed(() => [
label: props.unitLabel,
tip: t("rum.analytics.funnel.unitTooltip", { unit: props.unitLabel }),
},
{
id: "of-previous",
label: t("rum.analytics.funnel.ofPrevious"),
tip: t("rum.analytics.funnel.ofPreviousTooltip"),
},
...(props.compare
? [
{
id: "this-period",
label: t("rum.analytics.funnel.thisPeriod"),
tip: raw(props.compare.rangeExact),
},
{
id: "previous-period",
label: props.compare.label,
tip: raw(props.compare.prevRangeExact),
},
{
id: "change",
label: t("rum.analytics.funnel.changeHeader"),
tip: t("rum.analytics.funnel.changeHeaderTooltip"),
},
]
: [
{
id: "of-previous",
label: t("rum.analytics.funnel.ofPrevious"),
tip: t("rum.analytics.funnel.ofPreviousTooltip"),
},
]),
...(props.hideTime
? []
: [
@@ -289,7 +459,9 @@ const headerCols = computed(() => [
{
id: "of-first",
label: t("rum.analytics.funnel.ofFirst"),
tip: t("rum.analytics.funnel.ofFirstTooltip"),
tip: props.compare
? t("rum.analytics.funnel.ofFirstCompareTooltip")
: t("rum.analytics.funnel.ofFirstTooltip"),
},
]);
@@ -303,6 +475,39 @@ const numbersHeld = computed(
const pct = (v: number) => `${(v * 100).toFixed(1)}%`;
const compareData = computed(() =>
props.compare?.status === "ok" ? (props.compare.data ?? null) : null,
);
const compareFailed = computed(() => props.compare?.status === "failed");
// An empty previous period has no bar to draw, so the rows keep their compare-off bar.
const prevBars = computed(() => !!compareData.value && !compareData.value.empty);
const stepCompare = (i: number): StepCompare | null => compareData.value?.steps[i] ?? null;
const prevRate = (i: number): number | null =>
i === 0 ? null : (stepCompare(i)?.prevOfPrevious ?? null);
const dropCompare = (i: number): DropCompare | null =>
prevBars.value ? (compareData.value?.drops[i] ?? null) : null;
const dropParams = (i: number) => {
const d = dropCompare(i);
const r = props.result?.steps;
return {
pct: pct(r?.[i + 1] ? 1 - r[i + 1].ofPrevious : 0),
count: formatCount(r?.[i]?.dropoff ?? 0, props.sampled),
prevPct: pct(d?.prevRate ?? 0),
prevCount: formatCount(d?.prevCount ?? 0, props.sampled),
};
};
// Red is the same event as a red change on the next row; grey wins under the low-volume rule.
const dropVariant = (i: number) => {
const d = dropCompare(i);
if (d?.lowVolume) return "ghost-muted";
return d?.rose ? "ghost-destructive" : "ghost-primary";
};
const isDeleted = (s: StepRef) => s.kind === "e" && !props.events.some((e) => e.id === s.key);
const removedShare = (i: number): number => {
@@ -322,6 +527,8 @@ const apply = (edit: FunnelEdit | null) => {
emit("update:def", edit.def);
};
const stepFields = (i: number): SeenField[] => fieldsOnStep(props.fieldSample ?? null, i + 1);
const replaceStep = (i: number, step: StepRef) =>
emit("update:def", { ...props.def, steps: props.def.steps.map((s, j) => (j === i ? step : s)) });
@@ -21,17 +21,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="rum-analytics-funnel-field-picker"
>
<template v-if="!field">
<div v-if="fields.length" class="flex min-h-0 flex-1 flex-col max-lg:h-72">
<span
v-if="note"
class="text-text-secondary text-xs"
data-test="rum-analytics-funnel-field-picker-note"
>{{ note }}</span
>
<div v-if="hasFields" class="flex min-h-0 flex-1 flex-col max-lg:h-72">
<GroupedFieldList
keyboard
:fields="listFields"
:search="search"
search-class="px-0"
:row-key="groups ? 'rowId' : 'name'"
@update:search="(v: string) => (search = v)"
@row-click="onRowClick"
>
<template #field-row="{ row }">
<OFieldRow :data-test="`rum-analytics-funnel-field-picker-field-${row.field}`">
<OFieldRow v-if="groups" :data-test="`rum-analytics-funnel-field-picker-${row.rowId}`">
<span class="flex min-w-0 flex-1 items-center justify-between gap-2">
<OTruncatedText
as="span"
class="min-w-0 font-mono"
:class="row.reason ? 'text-text-muted' : 'text-text-body'"
>{{ row.name }}</OTruncatedText
>
<OTruncatedText
v-if="row.hint"
as="span"
class="text-text-secondary text-2xs max-w-[55%] min-w-0 shrink-0"
>{{ row.hint }}</OTruncatedText
>
</span>
<OTooltip v-if="row.reason" :content="row.reason" />
</OFieldRow>
<OFieldRow v-else :data-test="`rum-analytics-funnel-field-picker-field-${row.field}`">
<OFieldLabel :field="{ name: row.name }" />
</OFieldRow>
</template>
@@ -44,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</GroupedFieldList>
</div>
<span v-else class="text-text-secondary text-xs">{{
<span v-else-if="!note" class="text-text-secondary text-xs">{{
t("rum.analytics.funnel.filterNoFields")
}}</span>
</template>
@@ -166,6 +190,7 @@ import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OFieldRow from "@/lib/lists/FieldList/OFieldRow.vue";
import OFieldLabel from "@/lib/lists/FieldList/OFieldLabel.vue";
import GroupedFieldList from "@/components/common/GroupedFieldList.vue";
@@ -177,8 +202,9 @@ import {
filterFieldLabel,
type FilterCondition,
type FilterValuesState,
type PickerGroup,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { addCommasToNumber } from "@/utils/formatters";
import { isFilterColumn, MAX_FILTER_VALUES } from "@/utils/rum/productAnalyticsModel";
import type { FunnelFilter } from "@/utils/rum/productAnalyticsQueries";
@@ -192,11 +218,16 @@ const props = defineProps<{
initial?: FunnelFilter;
lockField?: boolean;
conditions?: readonly FilterCondition[];
/** Fixed bands in place of the Logs grouping; a row click then emits `pick` with the row's id. */
groups?: readonly PickerGroup[];
/** One line above the list, e.g. while a band is still loading. */
note?: I18nText | null;
}>();
const emit = defineEmits<{
load: [string];
apply: [FunnelFilter];
cancel: [];
pick: [string];
}>();
const { t } = useI18nTyped();
const { semanticIndex, keyFieldSet, keyGroupSet, loadGroupingContext } = useFieldGrouping();
@@ -209,8 +240,29 @@ const condition = ref<FilterCondition>(conditionOf(props.initial ?? { op: "in" }
const typed = ref("");
const search = ref("");
// Ids are unique across bands, so a field listed in two bands keeps two rows.
const bandRows = (groups: readonly PickerGroup[]) =>
groups
.filter((g) => g.fields.length)
.flatMap((g) => [
{ name: g.label, label: true, group: g.id, rowId: `band-${g.id}` },
...g.fields.map((f) => ({
name: f.name,
group: g.id,
rowId: f.id,
hint: f.hint,
reason: f.reason,
disabled: !!f.reason,
})),
]);
const hasFields = computed(() =>
props.groups ? props.groups.some((g) => g.fields.length) : props.fields.length > 0,
);
// Built-in fields are listed under their readable names, so the search finds "Browser family" too.
const listFields = computed(() => {
if (props.groups) return bandRows(props.groups);
const rows = props.fields.map((field): FieldObj => ({
name: filterFieldLabel(t, field),
field,
@@ -282,8 +334,19 @@ const pickField = (f: string) => {
focusIn("rum-analytics-funnel-field-picker-typed");
};
const onRowClick = (row: { name: string; field?: unknown; isGroup?: boolean }) => {
if (!row.isGroup) pickField(typeof row.field === "string" ? row.field : row.name);
const onRowClick = (row: {
name: string;
field?: unknown;
isGroup?: boolean;
rowId?: string;
reason?: unknown;
}) => {
if (row.isGroup) return;
if (props.groups) {
if (!row.reason && row.rowId) emit("pick", row.rowId);
return;
}
pickField(typeof row.field === "string" ? row.field : row.name);
};
const back = () => {
@@ -321,6 +384,6 @@ const apply = () => {
onMounted(() => {
if (field.value) emit("load", field.value);
// Grouping is best effort; without it the list stays flat, as in the Logs field list.
loadGroupingContext("logs").catch(() => undefined);
if (!props.groups) loadGroupingContext("logs").catch(() => undefined);
});
</script>
@@ -22,122 +22,256 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span v-if="!compact" class="text-text-heading text-sm font-semibold">{{
t("rum.analytics.funnel.stepFilters")
}}</span>
<OInput
type="number"
size="sm"
:min="0"
:max="MAX_STEP_MS / 1000"
:label="t('rum.analytics.funnel.filterMinTime')"
:model-value="step.f?.minMs !== undefined ? step.f.minMs / 1000 : ''"
:data-test="`rum-analytics-funnel-step-${index}-filter-min`"
@update:model-value="(v) => setFilters({ minMs: toMs(v) })"
/>
<OCheckbox
:model-value="!!step.f?.frustrated"
:label="t('rum.analytics.funnel.filterFrustrated')"
:data-test="`rum-analytics-funnel-step-${index}-filter-frustrated`"
@update:model-value="(v) => setFilters({ frustrated: v === true })"
/>
<OCheckbox
:model-value="!!step.f?.errored"
:label="t('rum.analytics.funnel.filterErrored')"
:data-test="`rum-analytics-funnel-step-${index}-filter-errored`"
@update:model-value="(v) => setFilters({ errored: v === true })"
/>
<div class="flex flex-col gap-2" :data-test="`rum-analytics-funnel-step-${index}-only-when`">
<template v-if="step.kind === 'p'">
<OInput
type="number"
size="sm"
:min="0"
:max="MAX_STEP_MS / 1000"
:label="t('rum.analytics.funnel.filterMinTime')"
:model-value="step.f?.minMs !== undefined ? step.f.minMs / 1000 : ''"
:data-test="`rum-analytics-funnel-step-${index}-filter-min`"
@update:model-value="(v) => setFilters({ minMs: toMs(v) })"
/>
<OCheckbox
:model-value="!!step.f?.frustrated"
:label="t('rum.analytics.funnel.filterFrustrated')"
:data-test="`rum-analytics-funnel-step-${index}-filter-frustrated`"
@update:model-value="(v) => setFilters({ frustrated: v === true })"
/>
<OCheckbox
:model-value="!!step.f?.errored"
:label="t('rum.analytics.funnel.filterErrored')"
:data-test="`rum-analytics-funnel-step-${index}-filter-errored`"
@update:model-value="(v) => setFilters({ errored: v === true })"
/>
<div class="flex flex-col gap-2" :data-test="`rum-analytics-funnel-step-${index}-only-when`">
<span class="text-text-heading text-xs font-semibold">{{
t("rum.analytics.funnel.onlyWhen")
}}</span>
<div
v-for="(d, i) in drafts"
:key="i"
class="flex items-start gap-1.5"
:class="compact ? 'flex-wrap' : ''"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}`"
>
<OSelect
:model-value="d.t"
:options="condTypes"
size="sm"
class="w-32 shrink-0"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-type`"
@update:model-value="(v) => setDraft(i, { t: v === 'ref' ? 'ref' : 'param' })"
/>
<OInput
v-if="d.t === 'param'"
size="sm"
class="w-28 shrink-0"
:model-value="d.key"
:placeholder="t('rum.analytics.funnel.condParam')"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-key`"
@update:model-value="(v) => setDraft(i, { key: String(v ?? '') })"
/>
<OInput
size="sm"
class="min-w-0 flex-1"
:class="compact ? 'order-last basis-full' : ''"
:model-value="d.values"
:placeholder="
d.t === 'param'
? t('rum.analytics.funnel.condValues')
: t('rum.analytics.funnel.condHosts')
"
:error="!!problems[i]"
:error-message="problems[i] ?? undefined"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-values`"
@update:model-value="(v) => setDraft(i, { values: String(v ?? '') })"
/>
<OButton
variant="ghost"
size="icon-sm"
icon-left="close"
:aria-label="t('rum.analytics.funnel.removeCond')"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-remove`"
@click="removeRow(i)"
><OTooltip :content="t('rum.analytics.funnel.removeCond')"
/></OButton>
</div>
<span>
<OButton
variant="ghost-primary"
size="xs"
icon-left="add"
:disabled="drafts.length >= MAX_VIEW_CONDS"
:data-test="`rum-analytics-funnel-step-${index}-cond-add`"
@click="addRow"
>{{ t("rum.analytics.funnel.addCond") }}</OButton
>
</span>
<span class="text-text-secondary text-xs">{{
t("rum.analytics.funnel.condSessionHint")
}}</span>
</div>
</template>
<div class="flex flex-col gap-2" :data-test="`rum-analytics-funnel-step-${index}-property`">
<span class="text-text-heading text-xs font-semibold">{{
t("rum.analytics.funnel.onlyWhen")
t("rum.analytics.funnel.propertyTitle")
}}</span>
<div
v-for="(d, i) in drafts"
v-for="(d, i) in propRows"
:key="i"
class="flex items-start gap-1.5"
:class="compact ? 'flex-wrap' : ''"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}`"
class="flex flex-col gap-1.5"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}`"
>
<OSelect
:model-value="d.t"
:options="condTypes"
size="sm"
class="w-32 shrink-0"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-type`"
@update:model-value="(v) => setDraft(i, { t: v === 'ref' ? 'ref' : 'param' })"
/>
<OInput
v-if="d.t === 'param'"
size="sm"
class="w-28 shrink-0"
:model-value="d.key"
:placeholder="t('rum.analytics.funnel.condParam')"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-key`"
@update:model-value="(v) => setDraft(i, { key: String(v ?? '') })"
/>
<OInput
size="sm"
class="min-w-0 flex-1"
:class="compact ? 'order-last basis-full' : ''"
:model-value="d.values"
:placeholder="
d.t === 'param'
? t('rum.analytics.funnel.condValues')
: t('rum.analytics.funnel.condHosts')
"
:error="!!problems[i]"
:error-message="problems[i] ?? undefined"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-values`"
@update:model-value="(v) => setDraft(i, { values: String(v ?? '') })"
/>
<OButton
variant="ghost"
size="icon-sm"
icon-left="close"
:aria-label="t('rum.analytics.funnel.removeCond')"
:data-test="`rum-analytics-funnel-step-${index}-cond-${i}-remove`"
@click="removeRow(i)"
><OTooltip :content="t('rum.analytics.funnel.removeCond')"
/></OButton>
<div class="flex items-center gap-1.5">
<span class="min-w-0 flex-1">
<OSelect
:model-value="d.field || null"
:options="fieldOptions(d.field)"
:placeholder="t('rum.analytics.funnel.propertyField')"
searchable
size="sm"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-field`"
@update:model-value="(v) => setProp(i, { field: String(v ?? ''), values: [] })"
/>
</span>
<span class="w-28 shrink-0">
<OSelect
:model-value="d.cond"
:options="propConds(d.cond)"
size="sm"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-op`"
@update:model-value="(v) => setProp(i, { cond: condId(v) })"
/>
</span>
<OButton
variant="ghost"
size="icon-sm"
icon-left="close"
:aria-label="t('rum.analytics.funnel.propertyRemove')"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-remove`"
@click="removeProp(i)"
><OTooltip :content="t('rum.analytics.funnel.propertyRemove')"
/></OButton>
</div>
<div v-if="d.field" class="flex flex-wrap items-center gap-1.5">
<OTag
v-for="v in d.values"
:key="v"
:label="valueLabel(d.field, v)"
variant="primary-outline"
size="sm"
removable
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-value-${v}`"
@remove="setProp(i, { values: d.values.filter((x) => x !== v) })"
/>
<OSelect
:model-value="null"
:options="valueOptions(d)"
:placeholder="t('rum.analytics.funnel.propertyAddValue')"
:loading="values.field === d.field && values.status === 'loading'"
searchable
creatable
size="sm"
width="sm"
:disabled="d.values.length >= MAX_FILTER_VALUES"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-add-value`"
@open="emit('filter-values', d.field)"
@update:model-value="(v) => addValue(i, v)"
@create="(v) => addValue(i, v)"
/>
</div>
<span
v-if="propProblem(d)"
class="text-error text-xs"
:data-test="`rum-analytics-funnel-step-${index}-prop-${i}-invalid`"
>{{ t("rum.analytics.funnel.propertyInvalid") }}</span
>
</div>
<span
v-if="fieldsNote"
class="text-text-secondary text-xs"
:data-test="`rum-analytics-funnel-step-${index}-prop-fields-note`"
>{{ fieldsNote }}</span
>
<span>
<OButton
variant="ghost-primary"
size="xs"
icon-left="add"
:disabled="drafts.length >= MAX_VIEW_CONDS"
:data-test="`rum-analytics-funnel-step-${index}-cond-add`"
@click="addRow"
>{{ t("rum.analytics.funnel.addCond") }}</OButton
:disabled="propRows.length >= MAX_STEP_PROP_CONDS || !fields.length"
:data-test="`rum-analytics-funnel-step-${index}-prop-add`"
@click="addProp"
>{{ t("rum.analytics.funnel.propertyAdd") }}</OButton
>
</span>
<span class="text-text-secondary text-xs">{{
t("rum.analytics.funnel.condSessionHint")
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { MAX_VIEW_CONDS } from "@/utils/rum/productAnalyticsModel";
import type { StepFilters, StepRef, ViewCond } from "@/utils/rum/productAnalyticsQueries";
import OTag from "@/lib/core/Badge/OTag.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { addCommasToNumber } from "@/utils/formatters";
import {
MAX_FILTER_VALUES,
MAX_STEP_PROP_CONDS,
MAX_VIEW_CONDS,
} from "@/utils/rum/productAnalyticsModel";
import type {
FunnelFilter,
StepFilters,
StepRef,
ViewCond,
} from "@/utils/rum/productAnalyticsQueries";
import type { FieldSampleState } from "@/composables/rum/useFunnelFieldSample";
import { MAX_STEP_MS, toMs } from "@/components/rum/productAnalytics/funnelParts/funnelEdits";
import {
condOf,
condProblem,
draftOf,
FILTER_CONDITIONS,
PROP_CONDITIONS,
propDraftOf,
propOf,
withConds,
withProps,
type FilterConditionId,
type FilterValuesState,
type PropDraft,
type ViewCondDraft,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
defineOptions({ name: "StepConditionEditor" });
const props = defineProps<{ step: StepRef; index: number; compact?: boolean }>();
const emit = defineEmits<{ update: [StepRef] }>();
const props = withDefaults(
defineProps<{
step: StepRef;
index: number;
compact?: boolean;
/** Fields the sample saw on this step's events, the only ones its Property rows offer. */
fields?: readonly string[];
fieldsState?: FieldSampleState;
values?: FilterValuesState;
}>(),
{
fields: () => [],
fieldsState: "idle",
values: () => ({ field: null, status: "idle", rows: [] }),
},
);
const emit = defineEmits<{
update: [StepRef];
"filter-values": [string];
"load-fields": [];
}>();
const { t } = useI18nTyped();
const condTypes = computed(() => [
@@ -187,6 +321,105 @@ const removeRow = (i: number) => {
emitConds();
};
const savedProps = () => props.step.f?.pc ?? [];
// A row without a field or a value stays local, so only rules a link can carry reach the funnel.
const blankProp = (): PropDraft => ({ field: "", cond: "in", values: [] });
const propRows = ref<PropDraft[]>(
savedProps().length ? savedProps().map(propDraftOf) : [blankProp()],
);
const appliedProps = (rows: PropDraft[]): FunnelFilter[] =>
rows.map(propOf).filter((r): r is FunnelFilter => r !== null);
// A linked rule's condition outside the four offered stays listed, so the row shows what it holds.
const propConds = (current: FilterConditionId) =>
FILTER_CONDITIONS.filter((c) => PROP_CONDITIONS.includes(c) || c.id === current).map((c) => ({
label: PROP_CONDITIONS.includes(c)
? t(`rum.analytics.funnel.propConditions.${c.id as "in" | "notIn" | "contains" | "regex"}`)
: t(`rum.analytics.funnel.conditions.${c.id}`),
value: c.id,
}));
const condId = (v: unknown): FilterConditionId =>
FILTER_CONDITIONS.find((c) => c.id === v)?.id ?? "in";
// A field from a link that the sample missed stays offered, so the row never loses its own field.
const fieldOptions = (current: string) =>
[...new Set([...props.fields, ...(current ? [current] : [])])].map((f) => ({
label: raw(f),
value: f,
}));
const countOf = (field: string, value: string): number | null => {
const v = props.values;
if (v.field !== field || v.status !== "ok") return null;
return v.rows.find((r) => r.value === value)?.sessions ?? null;
};
const valueLabel = (field: string, value: string): I18nText => {
const n = countOf(field, value);
return n === null
? raw(value)
: t("rum.analytics.funnel.propertyValueCount", {
value: raw(value),
count: raw(addCommasToNumber(n)),
});
};
const valueOptions = (d: PropDraft) =>
props.values.field === d.field && props.values.status === "ok"
? props.values.rows
.filter((r) => !d.values.includes(r.value))
.map((r) => ({ label: valueLabel(d.field, r.value), value: r.value }))
: [];
const propProblem = (d: PropDraft): boolean => !!d.field && !!d.values.length && !propOf(d);
const fieldsNote = computed<I18nText | null>(() => {
if (props.fieldsState === "loading") return t("rum.analytics.funnel.propertyFieldsLoading");
if (props.fieldsState === "error") return t("rum.analytics.funnel.propertyFieldsFailed");
if (props.fieldsState === "ok" && !props.fields.length)
return t("rum.analytics.funnel.propertyNoFields");
return null;
});
watch(
() => JSON.stringify(savedProps()),
(pc) => {
if (pc !== JSON.stringify(appliedProps(propRows.value)))
propRows.value = savedProps().length ? savedProps().map(propDraftOf) : [blankProp()];
},
);
const emitProps = () => {
const rules = appliedProps(propRows.value);
if (JSON.stringify(rules) !== JSON.stringify(savedProps()))
emit("update", withProps(props.step, rules));
};
const setProp = (i: number, patch: Partial<PropDraft>) => {
propRows.value = propRows.value.map((d, j) => (j === i ? { ...d, ...patch } : d));
emitProps();
};
const addValue = (i: number, v: unknown) => {
const value = String(v ?? "").trim();
const d = propRows.value[i];
if (!d || !value || d.values.includes(value) || d.values.length >= MAX_FILTER_VALUES) return;
setProp(i, { values: [...d.values, value] });
};
const addProp = () => {
if (propRows.value.length < MAX_STEP_PROP_CONDS)
propRows.value = [...propRows.value, blankProp()];
};
const removeProp = (i: number) => {
propRows.value = propRows.value.filter((_, j) => j !== i);
emitProps();
};
onMounted(() => emit("load-fields"));
const setFilters = (patch: { minMs?: number; frustrated?: boolean; errored?: boolean }) => {
const s = props.step;
const merged = { ...s.f, ...patch };
@@ -195,6 +428,7 @@ const setFilters = (patch: { minMs?: number; frustrated?: boolean; errored?: boo
...(merged.frustrated ? { frustrated: true as const } : {}),
...(merged.errored ? { errored: true as const } : {}),
...(merged.vc?.length ? { vc: merged.vc } : {}),
...(merged.pc?.length ? { pc: merged.pc } : {}),
};
emit(
"update",
@@ -28,7 +28,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/></OButton>
</slot>
</template>
<StepConditionEditor :step="step" :index="index" @update="(s) => emit('update', s)" />
<StepConditionEditor
:step="step"
:index="index"
:fields="fields"
:fields-state="fieldsState"
:values="values"
@update="(s) => emit('update', s)"
@filter-values="(f) => emit('filter-values', f)"
@load-fields="emit('load-fields')"
/>
</OPopover>
</template>
@@ -37,10 +46,22 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import StepConditionEditor from "@/components/rum/productAnalytics/funnelParts/StepConditionEditor.vue";
import type { FilterValuesState } from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
import type { FieldSampleState } from "@/composables/rum/useFunnelFieldSample";
import { useI18nTyped } from "@/types/i18n";
import type { StepRef } from "@/utils/rum/productAnalyticsQueries";
defineProps<{ step: StepRef; index: number }>();
const emit = defineEmits<{ update: [StepRef] }>();
defineProps<{
step: StepRef;
index: number;
fields?: readonly string[];
fieldsState?: FieldSampleState;
values?: FilterValuesState;
}>();
const emit = defineEmits<{
update: [StepRef];
"filter-values": [string];
"load-fields": [];
}>();
const { t } = useI18nTyped();
</script>
@@ -163,4 +163,36 @@ describe("funnelEdits", () => {
expect(removedByStep(def, [4, 2, 1])).toEqual([null, 2, 5]);
expect(removedByStep(def, [])).toEqual([null, null, null]);
});
describe("a pinned attribute breakdown follows its step", () => {
const pinned = (s: number): FunnelDef => ({
...defOf([A, B, C, D]),
breakdown: { f: "context_payment_method", s },
});
it("removing an earlier step shifts the pin down", () => {
expect(removeStep(pinned(3), 0).def.breakdown).toEqual({ f: "context_payment_method", s: 2 });
});
it("removing the pinned step falls back to automatic", () => {
expect(removeStep(pinned(3), 2).def.breakdown).toEqual({ f: "context_payment_method" });
});
it("removing a later step keeps the pin", () => {
expect(removeStep(pinned(3), 3).def.breakdown).toEqual({ f: "context_payment_method", s: 3 });
});
it("a swap carries the pin with its step", () => {
expect(moveStep(pinned(3), 2, -1)!.def.breakdown).toEqual({
f: "context_payment_method",
s: 2,
});
expect(moveStep(pinned(3), 0, 1)!.def.breakdown).toEqual({
f: "context_payment_method",
s: 3,
});
});
it("leaves automatic and built-in breakdowns alone", () => {
const auto = { ...defOf([A, B, C]), breakdown: { f: "context_payment_method" } };
expect(removeStep(auto, 0).def.breakdown).toEqual({ f: "context_payment_method" });
const builtin = { ...defOf([A, B, C]), breakdown: "browser" as const };
expect(removeStep(builtin, 0).def.breakdown).toBe("browser");
});
});
});
@@ -19,6 +19,7 @@ import {
encodeDef,
exclusionProblem,
funnelParam,
pinnedAfter,
stepRefParam,
} from "@/utils/rum/productAnalyticsModel";
import type { ExclusionStep, FunnelDef, StepRef } from "@/utils/rum/productAnalyticsQueries";
@@ -33,9 +34,15 @@ export interface FunnelEdit {
const sameStep = (a: StepRef, b: StepRef): boolean =>
JSON.stringify(stepRefParam(a)) === JSON.stringify(stepRefParam(b));
const withX = (def: FunnelDef, steps: StepRef[], kept: ExclusionStep[]): FunnelDef => ({
const withX = (
def: FunnelDef,
steps: StepRef[],
kept: ExclusionStep[],
pos: (k: number) => number | null,
): FunnelDef => ({
...def,
steps,
breakdown: pinnedAfter(def.breakdown, pos),
...(def.x ? { x: kept } : {}),
});
@@ -66,7 +73,7 @@ export function reorderSteps(def: FunnelDef, order: number[]): FunnelEdit {
if (inverted || exclusionProblem(steps, moved)) dropped.push(x);
else kept.push(moved);
}
return { def: withX(def, steps, kept), dropped };
return { def: withX(def, steps, kept, pos), dropped };
}
export function moveStep(def: FunnelDef, i: number, by: number): FunnelEdit | null {
@@ -95,6 +102,7 @@ export function removeStep(def: FunnelDef, i: number): FunnelEdit {
def,
def.steps.filter((_, j) => j !== i),
kept,
(k) => (k === n ? null : shift(k)),
),
dropped,
};
@@ -14,6 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { afterEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h } from "vue";
import { mount, type VueWrapper } from "@vue/test-utils";
const toastSpy = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: toastSpy }));
@@ -21,7 +22,10 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: toastSpy }));
import SessionFieldPicker from "./SessionFieldPicker.vue";
import FunnelFilterBar from "./FunnelFilterBar.vue";
import StepConditionPopover from "./StepConditionPopover.vue";
import FunnelStepsTable from "./FunnelStepsTable.vue";
import { filterNoticeOf, type PickerGroup } from "./funnelFilters";
import WhyEnteredPopover from "./WhyEnteredPopover.vue";
import useStepLabels from "./useStepLabels";
import { whyLines } from "./whyEntered";
import type { FilterValuesState } from "./funnelFilters";
import type { FunnelDef, StepRef } from "@/utils/rum/productAnalyticsQueries";
@@ -337,6 +341,45 @@ describe("StepConditionPopover, Only when", () => {
const [[step]] = w.emitted("update") as [StepRef][];
expect(step.f).toEqual({ errored: true, vc: [c] });
});
it("other step filters keep a property condition from a link", async () => {
const pc = [{ field: "context_payment_method", op: "in" as const, values: ["upi"] }];
const w = mountPopover({ kind: "p", key: "/a", f: { pc } });
await open(w);
await w
.find(dt("rum-analytics-funnel-step-0-filter-errored"))
.find('[role="checkbox"], input[type="checkbox"]')
.trigger("click");
const [[step]] = w.emitted("update") as [StepRef][];
expect(step.f).toEqual({ errored: true, pc });
});
});
describe("useStepLabels, property conditions", () => {
it("names each property condition on the step", () => {
let chips: string[] = [];
const Probe = defineComponent({
setup() {
const { filterChips } = useStepLabels(() => []);
chips = filterChips({
kind: "c",
key: "payment_info_submitted",
f: {
pc: [
{ field: "context_payment_method", op: "in", values: ["upi", "card"] },
{ field: "context_plan", op: "contains", values: ["pro"], not: true },
],
},
}).map(String);
return () => h("div");
},
});
wrapper = mount(Probe);
expect(chips).toEqual([
"context_payment_method is upi, card",
"context_plan does not contain pro",
]);
});
});
describe("WhyEnteredPopover", () => {
@@ -401,3 +444,335 @@ describe("WhyEnteredPopover", () => {
expect(w.emitted("retry")).toHaveLength(1);
});
});
describe("SessionFieldPicker with fixed bands", () => {
const GROUPS = [
{
id: "steps",
label: "On these steps" as never,
fields: [
{
id: "field-context_payment_method",
name: "context_payment_method",
hint: "Step 5 · card" as never,
},
],
},
{ id: "key", label: "Key fields" as never, fields: [{ id: "dim-browser", name: "Browser" }] },
{
id: "user",
label: "User" as never,
fields: [{ id: "field-usr_id", name: "usr_id", reason: "For filters only" as never }],
},
{ id: "empty", label: "Nothing" as never, fields: [] },
] as PickerGroup[];
const mountBands = () => {
wrapper = mount(SessionFieldPicker, {
props: { fields: [], values: { field: null, status: "idle", rows: [] }, groups: GROUPS },
attachTo: document.body,
});
return wrapper;
};
const row = (w: VueWrapper, id: string) => w.find(dt(`rum-analytics-funnel-field-picker-${id}`));
it("lists each band with its count, skips an empty one, and shows a field's step hint", () => {
const w = mountBands();
expect(w.text()).toContain("On these steps (1)");
expect(w.text()).toContain("Key fields (1)");
expect(w.text()).toContain("User (1)");
expect(w.text()).not.toContain("Nothing");
expect(row(w, "field-context_payment_method").text()).toContain("Step 5 · card");
});
it("search narrows the bands to matching fields", async () => {
const w = mountBands();
await w.find(`${dt("o-field-list-search")} input`).setValue("pay");
expect(row(w, "field-context_payment_method").exists()).toBe(true);
expect(row(w, "dim-browser").exists()).toBe(false);
});
it("a row click picks its id; a disabled row picks nothing", async () => {
const w = mountBands();
await row(w, "field-usr_id").trigger("click");
expect(w.emitted("pick")).toBeUndefined();
await row(w, "field-context_payment_method").trigger("click");
await row(w, "dim-browser").trigger("click");
expect(w.emitted("pick")).toEqual([["field-context_payment_method"], ["dim-browser"]]);
expect(w.emitted("load")).toBeUndefined();
});
});
describe("StepConditionPopover, Property", () => {
const PAY = "context_payment_method";
const VALUES: FilterValuesState = {
field: PAY,
status: "ok",
rows: [
{ value: "upi", sessions: 8 },
{ value: "card", sessions: 17 },
],
};
const mountClick = (step: StepRef = { kind: "c", key: "payment_info_submitted" }) => {
wrapper = mount(StepConditionPopover, {
props: { step, index: 4, fields: [PAY], fieldsState: "ok", values: VALUES },
global: { stubs },
attachTo: document.body,
});
return wrapper;
};
const select = (w: VueWrapper, id: string) =>
w.findAllComponents({ name: "OSelect" }).find((c) => c.attributes("data-test") === id)!;
const lastStep = (w: VueWrapper) => (w.emitted("update") as [StepRef][]).at(-1)![0];
it("a click step offers only the Property row, and picking a field then a value writes pc at once", async () => {
const w = mountClick();
await w.find(dt("rum-analytics-funnel-step-4-filters")).trigger("click");
expect(w.emitted("load-fields")).toHaveLength(1);
expect(w.find(dt("rum-analytics-funnel-step-4-filter-min")).exists()).toBe(false);
expect(w.find(dt("rum-analytics-funnel-step-4-property")).text()).toContain("Property");
select(w, "rum-analytics-funnel-step-4-prop-0-field").vm.$emit("update:modelValue", PAY);
await w.vm.$nextTick();
expect(w.emitted("update")).toBeUndefined();
select(w, "rum-analytics-funnel-step-4-prop-0-add-value").vm.$emit("open");
expect(w.emitted("filter-values")).toEqual([[PAY]]);
select(w, "rum-analytics-funnel-step-4-prop-0-add-value").vm.$emit("update:modelValue", "upi");
await w.vm.$nextTick();
expect(lastStep(w)).toEqual({
kind: "c",
key: "payment_info_submitted",
f: { pc: [{ field: PAY, op: "in", values: ["upi"] }] },
});
expect(w.find(dt("rum-analytics-funnel-step-4-prop-0-value-upi")).text()).toBe("upi · 8");
});
it("is not and matches rewrite the rule, and removing its last value drops pc", async () => {
const w = mountClick({
kind: "c",
key: "payment_info_submitted",
f: { pc: [{ field: PAY, op: "in", values: ["upi"] }] },
});
await w.find(dt("rum-analytics-funnel-step-4-filters")).trigger("click");
select(w, "rum-analytics-funnel-step-4-prop-0-op").vm.$emit("update:modelValue", "notIn");
await w.vm.$nextTick();
expect(lastStep(w).f?.pc).toEqual([{ field: PAY, op: "in", values: ["upi"], not: true }]);
select(w, "rum-analytics-funnel-step-4-prop-0-op").vm.$emit("update:modelValue", "regex");
await w.vm.$nextTick();
expect(lastStep(w).f?.pc).toEqual([{ field: PAY, op: "regex", values: ["upi"] }]);
await w.find(`${dt("rum-analytics-funnel-step-4-prop-0-value-upi")} button`).trigger("click");
await w.vm.$nextTick();
expect(lastStep(w)).toEqual({ kind: "c", key: "payment_info_submitted" });
});
it("keeps a linked negated contains rule as it is while another row is edited", async () => {
const plan = {
field: "context_plan",
op: "contains" as const,
values: ["pro"],
not: true as const,
};
const w = mountClick({ kind: "c", key: "payment_info_submitted", f: { pc: [plan] } });
await w.find(dt("rum-analytics-funnel-step-4-filters")).trigger("click");
const op = select(w, "rum-analytics-funnel-step-4-prop-0-op");
const ops = op.props("options") as { value: string }[];
expect(ops.map((o) => o.value)).toEqual(["in", "notIn", "contains", "notContains", "regex"]);
expect(op.props("modelValue")).toBe("notContains");
await w.find(dt("rum-analytics-funnel-step-4-prop-add")).trigger("click");
select(w, "rum-analytics-funnel-step-4-prop-1-field").vm.$emit("update:modelValue", PAY);
await w.vm.$nextTick();
select(w, "rum-analytics-funnel-step-4-prop-1-add-value").vm.$emit("update:modelValue", "upi");
await w.vm.$nextTick();
expect(lastStep(w).f?.pc).toEqual([plan, { field: PAY, op: "in", values: ["upi"] }]);
});
it("a step whose property rule is removed elsewhere goes back to one blank row", async () => {
const w = mountClick({
kind: "c",
key: "payment_info_submitted",
f: { pc: [{ field: PAY, op: "in", values: ["upi"] }] },
});
await w.find(dt("rum-analytics-funnel-step-4-filters")).trigger("click");
await w.setProps({ step: { kind: "c", key: "payment_info_submitted" } });
expect(w.find(dt("rum-analytics-funnel-step-4-prop-0")).exists()).toBe(true);
expect(w.find(dt("rum-analytics-funnel-step-4-prop-1")).exists()).toBe(false);
expect(select(w, "rum-analytics-funnel-step-4-prop-0-field").props("modelValue")).toBeNull();
});
it("says when the sample saw no field on this step", async () => {
wrapper = mount(StepConditionPopover, {
props: { step: { kind: "c", key: "x" }, index: 0, fields: [], fieldsState: "ok" },
global: { stubs },
attachTo: document.body,
});
await wrapper.find(dt("rum-analytics-funnel-step-0-filters")).trigger("click");
expect(wrapper.find(dt("rum-analytics-funnel-step-0-prop-fields-note")).text()).toBe(
"No fields seen on this step",
);
});
});
describe("FunnelStepsTable step menu", () => {
const PAY = "context_payment_method";
const def: FunnelDef = {
steps: [
{ kind: "c", key: "product_viewed" },
{ kind: "c", key: "payment_info_submitted" },
],
unit: "sessions",
window: "session",
breakdown: null,
};
const mountTable = (state: "ok" | "loading") => {
wrapper = mount(FunnelStepsTable, {
props: {
def,
result: null,
removedAt: [],
state: { status: "idle", rows: [], error: null, partial: null, key: null, sampled: 1 },
sampled: 1,
events: [],
eventsStatus: "ready",
unitLabel: "Sessions" as never,
unitNoun: "sessions" as never,
hideTime: true,
fieldSample: {
fields: [{ field: PAY, steps: [2], top: ["card", "upi"], more: 0, many: false }],
},
fieldSampleState: state,
},
global: {
stubs: {
...stubs,
VueDraggableNext: { template: "<div><slot /></div>" },
ODropdown: {
emits: ["update:open"],
template: `<div><div @click="$emit('update:open', true)"><slot name="trigger" /></div><slot /></div>`,
},
ODropdownSub: { template: '<div><slot name="trigger" /><slot /></div>' },
ODropdownItem: {
props: ["disabled"],
emits: ["select"],
template: '<button :disabled="disabled" @click="$emit(\'select\')"><slot /></button>',
},
},
},
attachTo: document.body,
});
return wrapper;
};
it("its entry asks the page to open the picker for that step, after loading the fields", async () => {
const w = mountTable("ok");
await w.find(dt("rum-analytics-funnel-step-1-menu")).trigger("click");
expect(w.emitted("load-fields")).toHaveLength(1);
expect(w.find(dt("rum-analytics-funnel-step-1-break-down")).text()).toBe(
"Break down by a property of this step",
);
await w.find(dt("rum-analytics-funnel-step-1-break-down")).trigger("click");
expect(w.emitted("break-down-step")).toEqual([[2]]);
});
it("offers Show conversion trend from this step on every step but the last, and asks for that step's trend", async () => {
const w = mountTable("ok");
const item = w.find(dt("rum-analytics-funnel-step-0-trend-from"));
expect(item.text()).toBe("Show conversion trend from this step");
expect(w.find(dt("rum-analytics-funnel-step-1-trend-from")).exists()).toBe(false);
await item.trigger("click");
expect(w.emitted("trend-from-step")).toEqual([[1]]);
});
it("pins each row's actions cell to the end on wide screens, so ⋮ stays in view when the table scrolls", () => {
const w = mountTable("ok");
for (const i of [0, 1]) {
const row = w.find(dt(`rum-analytics-funnel-step-${i}`)).element;
let cell = w.find(dt(`rum-analytics-funnel-step-${i}-menu`)).element as HTMLElement;
while (cell.parentElement && cell.parentElement !== row) cell = cell.parentElement;
expect(cell.parentElement).toBe(row);
const classes = [...cell.classList];
expect(classes).toContain("md:sticky");
expect(classes.some((c) => /^(md:)?end-0$/.test(c))).toBe(true);
expect(classes.some((c) => /^(md:)?bg-/.test(c))).toBe(true);
}
});
});
describe("Filter all steps notice", () => {
const PAY = "context_payment_method";
const def = (f: FunnelDef["f"]): FunnelDef => ({ ...DEF, f });
const UPI = { field: PAY, op: "in" as const, values: ["upi"] };
it("flags an attribute filter fewer than half of step 1's sessions carry", () => {
const row = { opened: 84, kept: 8, carry1: 41 };
expect(filterNoticeOf(def([UPI]), row)).toEqual({ field: PAY, kept: 8, opened: 84, carry: 41 });
expect(filterNoticeOf(def([UPI]), { ...row, carry1: 42 })).toBeNull();
});
it("indexes carry counts over session filters only, and never flags a built-in field", () => {
const country = { field: "geo_info_country", op: "in" as const, values: ["IN"] };
const email = { field: "usr_email", op: "in" as const, values: ["a@b.c"] };
const row = { opened: 10, kept: 2, carry1: 1, carry2: 2 };
expect(filterNoticeOf(def([country, email, UPI]), row)).toMatchObject({ field: PAY, carry: 2 });
expect(filterNoticeOf(def([country]), { opened: 10, kept: 1 })).toBeNull();
expect(filterNoticeOf(def([UPI]), null)).toBeNull();
expect(filterNoticeOf(def([UPI]), { opened: 0, kept: 0, carry1: 0 })).toBeNull();
});
it("never flags a negated filter, which keeps the sessions that lack the field", () => {
const row = { opened: 84, kept: 70, carry1: 41 };
expect(filterNoticeOf(def([{ ...UPI, not: true }]), row)).toBeNull();
});
it("shows the kept count with one way out, and the reason behind an info link", async () => {
wrapper = mount(FunnelFilterBar, {
props: {
def: def([UPI]),
fields: [PAY],
values: { field: null, status: "idle", rows: [] },
stacked: true,
notice: { field: PAY, kept: 8, opened: 84, carry: 36 },
unitNoun: "sessions" as never,
},
global: { stubs },
attachTo: document.body,
});
expect(wrapper.find(dt("rum-analytics-funnel-filter-notice")).text()).toContain(
"Keeps 8 of 84 sessions.",
);
await wrapper.find(dt("rum-analytics-funnel-filter-notice-break-down")).trigger("click");
expect(wrapper.emitted("break-down")).toEqual([[PAY]]);
await wrapper.find(dt("rum-analytics-funnel-filter-notice-why")).trigger("click");
expect(wrapper.find(dt("rum-analytics-funnel-filter-notice-reason")).text()).toContain(
"48 of the 84 sessions that opened step 1 never set context_payment_method",
);
});
it("the way out reads Break down by, or Replace breakdown with once a breakdown is set", async () => {
const mountNotice = (breakdown: FunnelDef["breakdown"]) =>
mount(FunnelFilterBar, {
props: {
def: { ...def([UPI]), breakdown },
fields: [PAY],
values: { field: null, status: "idle", rows: [] },
stacked: true,
notice: { field: PAY, kept: 8, opened: 84, carry: 36 },
unitNoun: "sessions" as never,
},
global: { stubs },
});
const action = dt("rum-analytics-funnel-filter-notice-break-down");
wrapper = mountNotice(null);
expect(wrapper.find(action).text()).toBe(`Break down by ${PAY}`);
wrapper.unmount();
wrapper = mountNotice("browser");
expect(wrapper.find(action).text()).toBe(`Replace breakdown with ${PAY}`);
await wrapper.find(action).trigger("click");
expect(wrapper.emitted("break-down")).toEqual([[PAY]]);
});
it("shows no notice without one", () => {
wrapper = mount(FunnelFilterBar, {
props: { def: def([UPI]), fields: [PAY], values: { field: null, status: "idle", rows: [] } },
global: { stubs },
});
expect(wrapper.find(dt("rum-analytics-funnel-filter-notice")).exists()).toBe(false);
});
});
@@ -16,6 +16,7 @@
import {
CREDENTIAL_PARAMS,
MAX_FILTER_VALUES,
MAX_STEP_PROP_CONDS,
MAX_VIEW_CONDS,
parseFunnelFilter,
parseStepRef,
@@ -24,12 +25,13 @@ import {
type SessionField,
} from "@/utils/rum/productAnalyticsModel";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import type {
FunnelDef,
FunnelFilter,
StepFilters,
StepRef,
ViewCond,
import {
isSessionFilter,
type FunnelDef,
type FunnelFilter,
type StepFilters,
type StepRef,
type ViewCond,
} from "@/utils/rum/productAnalyticsQueries";
export interface FilterValueRow {
@@ -76,6 +78,36 @@ export const FILTER_CONDITIONS: readonly FilterCondition[] = [
{ id: "notRegex", op: "regex", not: true },
];
/** A row a field picker lists under a fixed band; a `reason` shows it disabled. */
export interface PickerField {
id: string;
name: string;
hint?: I18nText;
reason?: I18nText;
}
/** A band of a field picker with its own label, in place of the Logs grouping. */
export interface PickerGroup {
id: string;
label: I18nText;
fields: PickerField[];
}
/** A step's Property row as edited; it applies once it has a field and a value. */
export interface PropDraft {
field: string;
cond: FilterConditionId;
values: string[];
}
/** An attribute filter that fewer than half of step 1's sessions ever carry, with the counts that show it. */
export interface FilterNotice {
field: string;
kept: number;
opened: number;
carry: number;
}
/** One "Only when" row as typed; `values` is the comma-separated text. */
export interface ViewCondDraft {
t: ViewCond["t"];
@@ -83,6 +115,13 @@ export interface ViewCondDraft {
values: string;
}
/** The conditions a step's Property row offers: is, is not, contains, matches. */
export const PROP_CONDITIONS: readonly FilterCondition[] = FILTER_CONDITIONS.filter((c) =>
["in", "notIn", "contains", "regex"].includes(c.id),
);
const NOTICE_SHARE = 0.5;
const splitValues = (text: string): string[] => [
...new Set(
text
@@ -158,3 +197,48 @@ export function withConds(step: StepRef, conds: ViewCond[]): StepRef {
? { kind: step.kind, key: step.key, f }
: { kind: step.kind, key: step.key };
}
export function propDraftOf(r: FunnelFilter): PropDraft {
return { field: r.field, cond: conditionOf(r).id, values: [...r.values] };
}
/** A Property row as the server would accept it, or null while it is incomplete or invalid. */
export function propOf(d: PropDraft): FunnelFilter | null {
if (!d.field || !d.values.length) return null;
// A rule from a link may use any Filter all steps condition, and it must come back unchanged.
const c = FILTER_CONDITIONS.find((x) => x.id === d.cond) ?? PROP_CONDITIONS[0];
const r = parseFunnelFilter({
field: d.field,
op: c.op,
values: [...new Set(d.values)],
...(c.not ? { not: true } : {}),
});
return r && isFilterColumn(r.field) ? r : null;
}
/** The step with `rules` as its property conditions, keeping its other filters. */
export function withProps(step: StepRef, rules: FunnelFilter[]): StepRef {
const f: StepFilters = { ...step.f, pc: rules.slice(0, MAX_STEP_PROP_CONDS) };
if (!rules.length) delete f.pc;
return Object.keys(f).length
? { kind: step.kind, key: step.key, f }
: { kind: step.kind, key: step.key };
}
/** The first attribute filter fewer than half of step 1's sessions ever carry, from a "Why N?" row. */
export function filterNoticeOf(
def: FunnelDef,
row: Record<string, unknown> | null,
): FilterNotice | null {
if (!row) return null;
const opened = Number(row.opened) || 0;
if (opened <= 0) return null;
const ids = (def.f ?? []).filter(isSessionFilter);
for (const [i, r] of ids.entries()) {
const carry = Number(row[`carry${i + 1}`]) || 0;
// A negated filter keeps the sessions that never set the field, so it drops nobody for lacking it.
if (!r.not && isFilterColumn(r.field) && carry / opened < NOTICE_SHARE)
return { field: r.field, kept: Number(row.kept) || 0, opened, carry };
}
return null;
}
@@ -0,0 +1,187 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { chartAxisLine, chartColor, chartGridLine, chartTextColor } from "@/utils/chartTheme";
import { getColorPalette } from "@/utils/dashboard/colorPalette";
import { escapeHtml } from "@/utils/html";
import type { I18nText, TranslateFn } from "@/types/i18n";
import type { TrendBucket, TrendPrevious, TrendSeries } from "@/utils/rum/funnelTrend";
const LINE_WIDTH = 1.5;
const SYMBOL_SIZE = 6;
/** Everything the trend chart draws; labels arrive already formatted for the viewer's zone. */
export interface TrendChartInput {
labels: string[];
series: { name: string; data: TrendSeries }[];
previous: { name: string; buckets: (TrendPrevious | null)[]; labels: string[] } | null;
stillConvertingName: I18nText;
bandLabel: (b: TrendBucket) => I18nText | null;
unitNoun: I18nText;
}
const pct = (v: number) => `${(v * 100).toFixed(1)}%`;
const toPct = (rate: number) => Math.round(rate * 1000) / 10;
const point = (b: TrendBucket): number | null =>
b.rate === null || b.stillConverting || b.beforeData ? null : toPct(b.rate);
// The hollow point sits apart; the dashed series joins it to the last closed bucket before it.
const stillConvertingData = (
buckets: TrendBucket[],
): ({ value: [number, number | null]; symbol?: string } | null)[] =>
buckets.map((b, i) => {
if (b.stillConverting && b.rate !== null && !b.beforeData) return { value: [i, toPct(b.rate)] };
const next = buckets[i + 1];
const v = point(b);
return next?.stillConverting && next.rate !== null && v !== null
? { value: [i, v], symbol: "none" }
: null;
});
const tooltipFor = (input: TrendChartInput, t: TranslateFn) => (raw: unknown) => {
const params = (Array.isArray(raw) ? raw : [raw]) as { dataIndex: number }[];
const i = params[0]?.dataIndex ?? 0;
const lines = [`<strong>${escapeHtml(input.labels[i] ?? "")}</strong>`];
for (const s of input.series) {
const b = s.data.buckets[i];
if (!b) continue;
const text =
b.rate === null
? (input.bandLabel(b) ?? t("rum.analytics.funnel.trendNoUnits", { unit: input.unitNoun }))
: t("rum.analytics.funnel.trendTooltipRate", {
rate: pct(b.rate),
reached: b.reached,
entered: b.entered,
});
lines.push(`${escapeHtml(s.name)}: ${escapeHtml(String(text))}`);
}
const p = input.previous?.buckets[i];
if (input.previous && p) {
const rate =
p.rate === null
? t("rum.analytics.funnel.trendNoUnits", { unit: input.unitNoun })
: pct(p.rate);
lines.push(
escapeHtml(
String(
t("rum.analytics.funnel.trendTooltipPrev", {
date: input.previous.labels[i] ?? "",
rate,
}),
),
),
);
}
return lines.join("<br/>");
};
/** ECharts options drawn like the dashboard line panel, plus the still-converting segment. */
export function trendChartOptions(input: TrendChartInput, t: TranslateFn): Record<string, unknown> {
const palette = getColorPalette();
const grey = chartColor("--color-text-secondary");
const anyRunning = input.series.some((s) => s.data.buckets.some((b) => b.stillConverting));
const series: Record<string, unknown>[] = input.series.flatMap((s, k) => {
const color = palette[k % palette.length];
const main = {
name: s.name,
type: "line",
smooth: true,
connectNulls: false,
symbol: "circle",
symbolSize: SYMBOL_SIZE,
color,
lineStyle: { width: LINE_WIDTH },
data: s.data.buckets.map((b, i) => [i, point(b)]),
};
if (!anyRunning) return [main];
return [
main,
{
name: input.stillConvertingName,
type: "line",
connectNulls: false,
symbol: "emptyCircle",
symbolSize: SYMBOL_SIZE,
color,
lineStyle: { width: LINE_WIDTH, type: "dashed" },
data: stillConvertingData(s.data.buckets),
},
];
});
if (input.previous) {
series.push({
name: input.previous.name,
type: "line",
smooth: true,
connectNulls: false,
symbol: "emptyCircle",
symbolSize: SYMBOL_SIZE,
color: grey,
lineStyle: { width: LINE_WIDTH, type: "dashed" },
data: input.previous.buckets.map((p, i) => [i, p && p.rate !== null ? toPct(p.rate) : null]),
});
}
const still = String(input.stillConvertingName);
const names = [...new Set(series.map((s) => String(s.name)))];
const prev = input.previous?.name ?? null;
const legend = [
...names.filter((n) => n !== still && n !== prev),
...names.filter((n) => n === still),
...(prev ? [prev] : []),
];
const n = input.labels.length;
return {
animation: false,
grid: { left: 28, right: 16, top: 16, bottom: 32, containLabel: true },
tooltip: { trigger: "axis", formatter: tooltipFor(input, t) },
legend: {
show: true,
bottom: 0,
type: "scroll",
data: legend.map((name) => (name === still ? { name, icon: "emptyCircle" } : { name })),
textStyle: { color: chartTextColor() },
},
xAxis: {
type: "value",
min: -0.5,
max: n - 0.5,
splitLine: { show: false },
axisLine: { onZero: false, lineStyle: { color: chartAxisLine() } },
axisTick: { show: false },
axisLabel: {
color: chartTextColor(),
hideOverlap: true,
customValues: input.labels.map((_, i) => i),
formatter: (v: number) => input.labels[v] ?? "",
},
axisPointer: { snap: true },
},
yAxis: {
type: "value",
min: 0,
max: 100,
name: t("rum.analytics.funnel.trendAxis"),
nameLocation: "middle",
nameGap: 36,
axisLine: { show: false, onZero: false },
axisTick: { show: false },
axisLabel: { color: chartTextColor() },
splitLine: { lineStyle: { type: "dashed", color: chartGridLine() } },
},
series,
};
}
@@ -17,8 +17,12 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import type { NamedEventsStatus } from "@/composables/rum/useNamedEvents";
import type { NamedEvent } from "@/utils/rum/productAnalyticsModel";
import type { StepKind, StepRef } from "@/utils/rum/productAnalyticsQueries";
import type { FunnelFilter, StepKind, StepRef } from "@/utils/rum/productAnalyticsQueries";
import { stepLabel } from "@/utils/rum/productAnalyticsPanels";
import {
conditionOf,
filterFieldLabel,
} from "@/components/rum/productAnalytics/funnelParts/funnelFilters";
const KIND_LABEL = {
p: "rum.analytics.kind.page",
@@ -51,6 +55,14 @@ export default function useStepLabels(
const kindVariant = (s: StepRef): BadgeVariant => KIND_VARIANT[s.kind];
// A Property row names its condition as the row's own select does.
const propCondition = (r: FunnelFilter): I18nText => {
const id = conditionOf(r).id;
return id === "in" || id === "notIn" || id === "contains" || id === "regex"
? t(`rum.analytics.funnel.propConditions.${id}`)
: t(`rum.analytics.funnel.conditions.${id}`);
};
const filterChips = (s: StepRef): I18nText[] => [
...(s.f?.minMs !== undefined
? [t("rum.analytics.funnel.filterOnPage", { seconds: s.f.minMs / 1000 })]
@@ -65,6 +77,13 @@ export default function useStepLabels(
})
: t("rum.analytics.funnel.condRefTag", { values: raw(c.values.join(" | ")) }),
),
...(s.f?.pc ?? []).map((r) =>
t("rum.analytics.funnel.filterChipCondition", {
field: filterFieldLabel(t, r.field),
condition: propCondition(r),
values: raw(r.values.join(", ")),
}),
),
];
return { labelOf, kindLabel, kindVariant, filterChips };
@@ -0,0 +1,94 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, type ComputedRef } from "vue";
import type useAnalyticsSearch from "@/composables/rum/useAnalyticsSearch";
import type useProductAnalytics from "@/composables/rum/useProductAnalytics";
import {
FIELD_SAMPLE_ROWS,
foldFieldSample,
isSampledField,
type FieldSample,
} from "@/utils/rum/funnelFields";
import type { NamedEvent } from "@/utils/rum/productAnalyticsModel";
import { stepFieldSampleSql, type FunnelDef } from "@/utils/rum/productAnalyticsQueries";
export type FieldSampleState = "idle" | "loading" | "ok" | "error";
interface SampleDeps {
pa: ReturnType<typeof useProductAnalytics>;
runner: ReturnType<typeof useAnalyticsSearch>;
def: ComputedRef<FunnelDef>;
events: ComputedRef<NamedEvent[]>;
}
const EMPTY: FieldSample = { fields: [] };
/** The fields each funnel step's events carry, from a per-step sample run once per funnel on demand. */
export default function useFunnelFieldSample(deps: SampleDeps) {
const { pa, runner, def, events } = deps;
const panel = runner.panel<Record<string, unknown>>("field-sample");
const fields = computed(() =>
Object.keys(pa.schema.value)
.filter((f) => pa.schema.value[f] && isSampledField(f))
.sort(),
);
// Step filters never change which rows an event has, so only the steps' targets key the sample.
const key = computed(() => {
const steps = def.value.steps;
const rules = steps
.filter((s) => s.kind === "e")
.map((s) => events.value.find((e) => e.id === s.key)?.rules ?? null);
return `${pa.scopeKey.value}|${JSON.stringify(steps.map((s) => [s.kind, s.key]))}|${JSON.stringify(rules)}|${fields.value.join(",")}`;
});
const state = computed<FieldSampleState>(() => {
if (!def.value.steps.length) return "idle";
if (!fields.value.length) return "ok";
const p = panel.value;
if (p.key !== key.value) return "idle";
if (p.status === "ok" || p.status === "loading") return p.status;
return p.status === "error" || p.status === "forbidden" ? "error" : "idle";
});
const sample = computed<FieldSample | null>(() => {
if (state.value !== "ok") return null;
if (!fields.value.length) return EMPTY;
return foldFieldSample(panel.value.rows, def.value.steps, fields.value);
});
/** Runs the sample unless this funnel's is answered or running; `force` reruns it. */
const load = (force = false) => {
const d = def.value;
if (!d.steps.length || !fields.value.length) return;
const k = key.value;
const p = panel.value;
if (!force && p.key === k && (p.status === "ok" || p.status === "loading")) return;
void runner.run(
"field-sample",
{
sql: stepFieldSampleSql(pa.scope.value, d, events.value, fields.value, FIELD_SAMPLE_ROWS),
...pa.resolveRange(),
limit: FIELD_SAMPLE_ROWS * d.steps.length,
sampled: 1,
},
k,
);
};
return { state, sample, load };
}
@@ -0,0 +1,190 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi } from "vitest";
import { computed, ref, type Ref } from "vue";
import useFunnelTrend from "@/composables/rum/useFunnelTrend";
import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import { raw, type TranslateFn } from "@/types/i18n";
import type { FunnelDef, FunnelPair, Granularity } from "@/utils/rum/productAnalyticsQueries";
type CompareMode = "prev" | "1h" | "1d" | "1w";
const DAY = 86_400_000_000;
const START = Date.UTC(2026, 9, 8) * 1000;
const steps = (n: number) =>
Array.from({ length: n }, (_, i) => ({ kind: "p" as const, key: `/s${i + 1}` }));
const setup = (compare: Ref<CompareMode | null> = ref(null)) => {
let release: () => void = () => {};
const pending = new Promise<null>((r) => (release = () => r(null)));
const panels = new Map<string, Ref<PanelState<unknown>>>();
const runner = {
panel: (id: string) => {
if (!panels.has(id))
panels.set(
id,
ref({ status: "idle", rows: [], error: null, partial: null, key: null, sampled: 1 }),
);
return panels.get(id)!;
},
run: vi.fn(async (id: string, _spec: { sql: string }, key: string) => {
const p = runner.panel(id);
p.value = { ...p.value, status: "loading", key };
return p.value;
}),
hold: vi.fn((id: string) => {
runner.panel(id).value = { ...runner.panel(id).value, status: "idle", key: null };
}),
};
const pa = {
funnelView: ref<"steps" | "trend">("trend"),
funnelPair: ref<FunnelPair | null>(null),
funnelInterval: ref<Granularity>("auto"),
range: ref({ startUs: START, endUs: START + 7 * DAY }),
dataStartUs: ref<number | null>(null),
summary: ref(null),
scope: ref({ app: "web", env: [], version: [], schema: {} }),
loadDataStart: vi.fn(() => pending),
newUsersRange: (r: { startUs: number; endUs: number }) => r,
};
const def = ref<FunnelDef>({
steps: steps(3),
unit: "sessions",
window: "session",
breakdown: null,
});
const trend = useFunnelTrend({
pa: pa as never,
runner: runner as never,
t: ((k: string) => raw(k)) as unknown as TranslateFn,
def: computed(() => def.value),
usersMode: computed(() => false),
identitySql: ref(null),
events: computed(() => []),
timezone: computed(() => "UTC"),
funnelKey: computed(() => JSON.stringify(def.value.steps)),
compareMode: computed(() => compare.value),
stepNames: computed(() => def.value.steps.map((s) => s.key)),
});
return { trend, runner, pa, def, release, compare };
};
describe("useFunnelTrend", () => {
it("a run whose funnel changed while it waited leaves the run to the newer funnel", async () => {
const { trend, runner, def, release } = setup();
const first = trend.run(def.value);
def.value = { ...def.value, steps: steps(4) };
const second = trend.run(def.value);
release();
await Promise.all([first, second]);
const trendRuns = runner.run.mock.calls.filter((c) => c[0] === "funnel-trend");
expect(trendRuns).toHaveLength(1);
expect(trendRuns[0][1].sql).toContain("'/s4'");
expect(trendRuns[0][1].sql).toContain("SUM(r4) AS cb");
expect(trendRuns[0][2]).toBe(trend.key.value);
});
it("the interval setter keeps only values the link can round-trip", () => {
const { trend, pa } = setup();
trend.setInterval("week");
expect(pa.funnelInterval.value).toBe("week");
trend.setInterval("hour");
expect(pa.funnelInterval.value).toBe("auto");
});
it("from lists steps 1 to n-1 and to lists only the steps after from", () => {
const { trend, pa, def } = setup();
def.value = { ...def.value, steps: steps(4) };
const values = (o: { value: unknown }[]) => o.map((x) => x.value);
expect(values(trend.fromOptions.value)).toEqual([1, 2, 3]);
expect(values(trend.toOptions.value)).toEqual([2, 3, 4]);
pa.funnelPair.value = { a: 2, b: 3 };
expect(values(trend.fromOptions.value)).toEqual([1, 2, 3]);
expect(values(trend.toOptions.value)).toEqual([3, 4]);
});
it("setFrom and setTo write the pair, and step 1 to the last step stays out of the link", () => {
const { trend, pa, def } = setup();
def.value = { ...def.value, steps: steps(6) };
trend.setFrom(2);
expect(pa.funnelPair.value).toEqual({ a: 2, b: 6 });
expect(trend.pair.value).toEqual({ a: 2, b: 6 });
trend.setTo(4);
expect(pa.funnelPair.value).toEqual({ a: 2, b: 4 });
expect(trend.pair.value).toEqual({ a: 2, b: 4 });
trend.setFrom(3);
expect(pa.funnelPair.value).toEqual({ a: 3, b: 4 });
trend.setFrom(1);
trend.setTo(6);
expect(pa.funnelPair.value).toBeNull();
expect(trend.pair.value).toEqual({ a: 1, b: 6 });
});
it("moving from to or past to sets to to the last step", () => {
const { trend, pa, def } = setup();
def.value = { ...def.value, steps: steps(6) };
pa.funnelPair.value = { a: 2, b: 4 };
trend.setFrom(4);
expect(pa.funnelPair.value).toEqual({ a: 4, b: 6 });
pa.funnelPair.value = { a: 2, b: 4 };
trend.setFrom(5);
expect(pa.funnelPair.value).toEqual({ a: 5, b: 6 });
});
it("a removed step resets a pair the funnel can no longer hold to step 1 to the last step", () => {
const { trend, pa, def } = setup();
def.value = { ...def.value, steps: steps(4) };
pa.funnelPair.value = { a: 3, b: 4 };
expect(trend.pair.value).toEqual({ a: 3, b: 4 });
def.value = { ...def.value, steps: steps(3) };
expect(trend.pair.value).toEqual({ a: 1, b: 3 });
expect(trend.fromOptions.value.map((o) => o.value)).toEqual([1, 2]);
expect(trend.toOptions.value.map((o) => o.value)).toEqual([2, 3]);
});
it("keys the previous run by the compare mode, so switching the mode runs the new window", async () => {
const compare = ref<CompareMode | null>("prev");
const { trend, runner, pa, def, release } = setup(compare);
pa.range.value = { startUs: START, endUs: START + 3 * DAY };
release();
await trend.run(def.value);
const prevRuns = () => runner.run.mock.calls.filter((c) => c[0] === "funnel-trend-prev");
expect(prevRuns()).toHaveLength(1);
expect(prevRuns()[0][2]).toBe(`${trend.key.value}|prev:prev`);
expect(prevRuns()[0][1]).toMatchObject({ startUs: START - 3 * DAY, endUs: START });
compare.value = "1w";
await trend.run(def.value);
expect(prevRuns()).toHaveLength(2);
expect(prevRuns()[1][2]).toBe(`${trend.key.value}|prev:1w`);
expect(prevRuns()[1][1]).toMatchObject({ startUs: START - 7 * DAY, endUs: START - 4 * DAY });
});
it("names the previous series with the dates the mode compares with", async () => {
const compare = ref<CompareMode | null>("1w");
const { trend, pa, def, release } = setup(compare);
pa.range.value = { startUs: START, endUs: START + 3 * DAY };
release();
await trend.run(def.value);
expect(trend.previous.value?.range).toBe("1–3 Oct");
expect(String(trend.previous.value?.label)).toBe("rum.analytics.funnel.compareAgo1w");
compare.value = "prev";
expect(trend.previous.value?.range).toBe("5–7 Oct");
expect(String(trend.previous.value?.label)).toBe("rum.analytics.funnel.comparePrev");
compare.value = null;
expect(trend.previous.value).toBeNull();
});
});
+369
View File
@@ -0,0 +1,369 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { computed, ref, type ComputedRef, type Ref } from "vue";
import type useAnalyticsSearch from "@/composables/rum/useAnalyticsSearch";
import type { PanelState } from "@/composables/rum/useAnalyticsSearch";
import type useProductAnalytics from "@/composables/rum/useProductAnalytics";
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import {
COMPARE_MODE_LABEL_KEYS,
compareWindow,
formatWindow,
type FunnelCompareMode,
type UsWindow,
} from "@/utils/rum/funnelCompare";
import {
alignPrevious,
foldTrend,
pairParam,
parseInterval,
previousTrendPeriods,
resolveInterval,
resolvePair,
trendPeriods,
type TrendContext,
type TrendPeriods,
type TrendPreviousView,
type TrendSeries,
} from "@/utils/rum/funnelTrend";
import {
BREAKDOWN_TOP,
autoGranularity,
granularityOptions,
type NamedEvent,
type Period,
} from "@/utils/rum/productAnalyticsModel";
import {
WINDOW_MS,
funnelTrendSql,
type FunnelDef,
type FunnelPair,
type Granularity,
type IdentitySql,
} from "@/utils/rum/productAnalyticsQueries";
interface TrendDeps {
pa: ReturnType<typeof useProductAnalytics>;
runner: ReturnType<typeof useAnalyticsSearch>;
t: TranslateFn;
def: ComputedRef<FunnelDef>;
usersMode: ComputedRef<boolean>;
identitySql: Readonly<Ref<IdentitySql | null>>;
events: ComputedRef<NamedEvent[]>;
timezone: ComputedRef<string>;
funnelKey: ComputedRef<string>;
/** The mode the range actually compares with; null while compare is off or not allowed. */
compareMode: ComputedRef<FunnelCompareMode | null>;
stepNames: ComputedRef<readonly string[]>;
/** The step a breakdown's lines are ranked at; null while it is still unknown. */
rankStep?: ComputedRef<number | null>;
}
const WAITING: PanelState<unknown> = {
status: "loading",
rows: [],
error: null,
partial: null,
key: null,
sampled: 1,
};
/** The funnel's Trend view: its runs, folds and select options; runs start only while Trend is shown. */
export default function useFunnelTrend(deps: TrendDeps) {
const { pa, runner, t, def, timezone } = deps;
const panel = runner.panel<Record<string, unknown>>("funnel-trend");
const prevPanel = runner.panel<Record<string, unknown>>("funnel-trend-prev");
// Fixed per run, so which bucket is still converting does not shift between renders.
const nowUs = ref(Date.now() * 1000);
const shown = computed(() => pa.funnelView.value === "trend" && def.value.steps.length > 1);
const pair = computed<FunnelPair>(() => resolvePair(pa.funnelPair.value, def.value.steps.length));
const interval = computed<Period | null>(() => {
const r = pa.range.value;
return r
? resolveInterval(r, timezone.value, pa.dataStartUs.value, pa.funnelInterval.value)
: null;
});
const periodsResult = computed(() => {
const r = pa.range.value;
return r ? trendPeriods(r, timezone.value, nowUs.value, interval.value) : null;
});
const periods = computed<TrendPeriods | null>(() => {
const p = periodsResult.value;
return p && "boundariesUs" in p ? p : null;
});
const issue = computed<"tooShort" | "tooMany" | null>(() => {
const p = periodsResult.value;
if (!p) return null;
if ("tooShort" in p) return "tooShort";
return "tooMany" in p ? "tooMany" : null;
});
const breakdown = computed(() => !!def.value.breakdown);
// A breakdown's lines are its own comparison, so the previous period runs only without one.
const comparing = computed(() => !!deps.compareMode.value && !breakdown.value);
const prevKey = (k: string | null) => `${k}|prev:${deps.compareMode.value}`;
const rank = computed(() => (deps.rankStep ? deps.rankStep.value : 1));
// An unknown rank holds the run, so its lines never start from a ranking the table will not use.
const key = computed(() => {
const p = periods.value;
return p && rank.value !== null
? `${deps.funnelKey.value}|trend|${pairParam(pair.value)}|${p.boundariesUs.join(",")}|${rank.value}`
: null;
});
const windowUs = computed(() => {
const w = def.value.window;
return deps.usersMode.value && w !== "session" ? WINDOW_MS[w] * 1000 : null;
});
const context = (range: UsWindow, dataThroughUs: number | null): TrendContext => ({
range,
nowUs: nowUs.value,
dataStartUs: pa.dataStartUs.value,
dataThroughUs,
windowUs: windowUs.value,
});
const state = computed<PanelState<unknown>>(() =>
panel.value.key === key.value && key.value !== null ? panel.value : WAITING,
);
const series = computed<TrendSeries[]>(() => {
const p = periods.value;
const r = pa.range.value;
if (!p || !r || state.value.status !== "ok") return [];
const ctx = context(r, pa.summary.value?.dataThroughUs ?? null);
return foldTrend(panel.value.rows, p, ctx, breakdown.value);
});
const previous = computed<TrendPreviousView | null>(() => {
const r = pa.range.value;
const p = periods.value;
const mode = deps.compareMode.value;
if (!comparing.value || !mode || !r || !p) return null;
const prev = compareWindow(r, mode);
const pp = previousTrendPeriods(prev, timezone.value, nowUs.value, p.granularity);
const range = formatWindow(prev, timezone.value);
const label = t(COMPARE_MODE_LABEL_KEYS[mode]);
const k = prevPanel.value;
if (!pp || k.key !== prevKey(key.value))
return { status: "loading", buckets: [], range, label };
if (k.status === "error" || k.status === "forbidden")
return { status: "failed", buckets: [], range, label };
if (k.status !== "ok" || !series.value[0])
return { status: "loading", buckets: [], range, label };
const before = foldTrend(k.rows, pp, context(prev, null), false)[0] ?? null;
return { status: "ok", buckets: alignPrevious(series.value[0], before), range, label };
});
const stepOption = (n: number) => {
const label = raw(`${n} · ${deps.stepNames.value[n - 1] ?? ""}`);
return {
label:
n === def.value.steps.length
? t("rum.analytics.funnel.trendLastStep", { step: label })
: label,
value: n,
};
};
const fromOptions = computed(() =>
Array.from({ length: Math.max(0, def.value.steps.length - 1) }, (_, i) => stepOption(i + 1)),
);
const toOptions = computed(() =>
Array.from({ length: def.value.steps.length - pair.value.a }, (_, i) =>
stepOption(pair.value.a + i + 1),
),
);
const pairLabel = computed<I18nText>(() =>
t("rum.analytics.funnel.trendPairOption", { a: pair.value.a, b: pair.value.b }),
);
const intervalName = (g: Period): I18nText =>
g === "week"
? t("rum.analytics.funnel.intervalNameWeek")
: g === "month"
? t("rum.analytics.funnel.intervalNameMonth")
: t("rum.analytics.funnel.intervalNameDay");
const reasonText = (reason: string | null): I18nText | null => {
if (!reason) return null;
if (reason.startsWith("dataDays:"))
return t("rum.analytics.retention.onlyDays", { n: Number(reason.slice(9)) });
return reason === "tooMany"
? t("rum.analytics.retention.reasonTooMany")
: t("rum.analytics.retention.reasonTooFew");
};
const intervalOptions = computed(() => {
const r = pa.range.value;
if (!r) return [];
const auto = autoGranularity(r.startUs, r.endUs, pa.dataStartUs.value);
const labels: Record<Granularity, I18nText> = {
auto: auto
? t("rum.analytics.funnel.intervalAutoResolved", { interval: intervalName(auto) })
: t("rum.analytics.funnel.intervalAuto"),
day: t("rum.analytics.funnel.intervalDay"),
week: t("rum.analytics.funnel.intervalWeek"),
month: t("rum.analytics.funnel.intervalMonth"),
};
return granularityOptions(r.startUs, r.endUs, timezone.value, pa.dataStartUs.value).map((o) => {
const why = reasonText(o.reason);
return {
label: why
? t("rum.analytics.retention.perDisabled", { label: labels[o.value], reason: why })
: labels[o.value],
value: o.value,
disabled: o.disabled,
};
});
});
// Step 1 to the last step is the default, so it stays out of the link.
const writePair = (a: number, b: number) => {
pa.funnelPair.value = a === 1 && b === def.value.steps.length ? null : { a, b };
};
const setFrom = (v: unknown) => {
const a = Number(v);
const n = def.value.steps.length;
if (!Number.isInteger(a) || a < 1 || a >= n) return;
writePair(a, pair.value.b > a ? pair.value.b : n);
};
const setTo = (v: unknown) => {
const b = Number(v);
if (!Number.isInteger(b) || b <= pair.value.a || b > def.value.steps.length) return;
writePair(pair.value.a, b);
};
const setInterval = (v: unknown) => {
pa.funnelInterval.value = parseInterval(v);
};
const hold = () => {
runner.hold("funnel-trend", "idle");
runner.hold("funnel-trend-prev", "idle");
};
const runPrevious = (
d: FunnelDef,
k: string,
cur: UsWindow,
g: Period,
mode: FunnelCompareMode,
) => {
const prev = compareWindow(cur, mode);
const pp = previousTrendPeriods(prev, timezone.value, nowUs.value, g);
if (!pp) return runner.hold("funnel-trend-prev", "idle");
const s = pa.scope.value;
const scope =
s.newSince === undefined ? s : { ...s, newSince: Math.trunc(prev.startUs / 1000) };
void runner.run(
"funnel-trend-prev",
{
sql: funnelTrendSql(scope, deps.identitySql.value, d, pp.boundariesUs, pair.value, {
events: deps.events.value,
sample: 1,
}),
...pa.newUsersRange(prev, d.unit === "users" && !!deps.identitySql.value),
limit: pp.boundariesUs.length,
sampled: 1,
},
prevKey(k),
);
};
/** Runs the trend for the funnel on screen; an answered or running key is left alone unless forced. */
const run = async (d: FunnelDef, force = false) => {
if (!shown.value) return hold();
const before = key.value;
await pa.loadDataStart();
// A newer funnel or first-data day changed the key meanwhile, and the key watcher runs that one.
if (key.value !== before) return;
const k = key.value;
const p = periods.value;
const cur = pa.range.value;
if (!shown.value || !k || !p || !cur) return hold();
const done = (s: PanelState<unknown>, id: string) =>
s.key === id && (s.status === "ok" || s.status === "loading");
if (!done(panel.value, k) || force) {
void runner.run(
"funnel-trend",
{
sql: funnelTrendSql(
pa.scope.value,
deps.identitySql.value,
d,
p.boundariesUs,
pair.value,
{
events: deps.events.value,
sample: 1,
},
rank.value ?? 1,
),
...pa.newUsersRange(cur, d.unit === "users" && !!deps.identitySql.value),
limit: p.boundariesUs.length * (d.breakdown ? BREAKDOWN_TOP + 1 : 1),
sampled: 1,
},
k,
);
}
const mode = deps.compareMode.value;
if (!comparing.value || !mode) runner.hold("funnel-trend-prev", "idle");
else if (!done(prevPanel.value, prevKey(k)) || force)
runPrevious(d, k, cur, p.granularity, mode);
};
/** A new run reads the clock again, so the still-converting bucket follows the day. */
const tick = () => {
nowUs.value = Date.now() * 1000;
};
const rangeText = computed(() => {
const r = pa.range.value;
return r ? formatWindow(r, timezone.value) : "";
});
return {
shown,
key,
pair,
pairLabel,
interval,
periods,
issue,
state,
series,
previous,
breakdown,
fromOptions,
toOptions,
intervalOptions,
rangeText,
setFrom,
setTo,
setInterval,
hold,
run,
tick,
};
}
@@ -212,6 +212,103 @@ describe("useProductAnalytics", () => {
expect(pa.state.env).toEqual(["prod"]);
});
it("round-trips compare to the previous period as fc=prev, never inside the funnel", () => {
const pa = useProductAnalytics();
const funnel = encodeDef({ s: [["p", "/a"]], u: "sessions", w: "session" });
pa.initFromRoute({ app: "web", funnel, fc: "prev" });
expect(pa.funnelCompare.value).toBe("prev");
const q = pa.toQuery("productAnalyticsFunnelBuilder");
expect(q.fc).toBe("prev");
expect(decodeDef(q.funnel as string)).not.toHaveProperty("fc");
expect(pa.toQuery("productAnalyticsFunnels")).not.toHaveProperty("fc");
resetProductAnalytics();
pa.initFromRoute(q);
expect(pa.funnelCompare.value).toBe("prev");
pa.initFromRoute({ app: "web", funnel });
expect(pa.funnelCompare.value).toBeNull();
expect(pa.toQuery("productAnalyticsFunnelBuilder")).not.toHaveProperty("fc");
});
it.each(["1h", "1d", "1w"] as const)(
"round-trips compare to %s ago as fc, never inside the funnel",
(mode) => {
const pa = useProductAnalytics();
const funnel = encodeDef({ s: [["p", "/a"]], u: "sessions", w: "session" });
pa.initFromRoute({ app: "web", funnel, fc: mode });
expect(pa.funnelCompare.value).toBe(mode);
const q = pa.toQuery("productAnalyticsFunnelBuilder");
expect(q.fc).toBe(mode);
expect(decodeDef(q.funnel as string)).not.toHaveProperty("fc");
resetProductAnalytics();
expect(pa.funnelCompare.value).toBeNull();
pa.initFromRoute(q);
expect(pa.funnelCompare.value).toBe(mode);
},
);
it("writes fc from the chosen mode and drops it when compare is off", () => {
const pa = useProductAnalytics();
const funnel = encodeDef({ s: [["p", "/a"]], u: "sessions", w: "session" });
pa.initFromRoute({ app: "web", funnel });
pa.funnelCompare.value = "1d";
expect(pa.toQuery("productAnalyticsFunnelBuilder").fc).toBe("1d");
pa.funnelCompare.value = null;
expect(pa.toQuery("productAnalyticsFunnelBuilder")).not.toHaveProperty("fc");
});
it("round-trips the trend view, its pair and interval as fv, fp and fi, never inside the funnel", () => {
const pa = useProductAnalytics();
const funnel = encodeDef({
s: [
["p", "/a"],
["c", "b"],
["p", "/c"],
],
u: "sessions",
w: "session",
});
pa.initFromRoute({ app: "web", funnel, fv: "trend", fp: "2-3", fi: "week" });
expect(pa.funnelView.value).toBe("trend");
expect(pa.funnelPair.value).toEqual({ a: 2, b: 3 });
expect(pa.funnelInterval.value).toBe("week");
const q = pa.toQuery("productAnalyticsFunnelBuilder");
expect([q.fv, q.fp, q.fi]).toEqual(["trend", "2-3", "week"]);
expect(decodeDef(q.funnel as string)).not.toHaveProperty("fv");
expect(pa.toQuery("productAnalyticsFunnels")).not.toHaveProperty("fv");
resetProductAnalytics();
pa.initFromRoute(q);
expect([pa.funnelView.value, pa.funnelInterval.value]).toEqual(["trend", "week"]);
expect(pa.funnelPair.value).toEqual({ a: 2, b: 3 });
pa.initFromRoute({ app: "web", funnel });
expect([pa.funnelView.value, pa.funnelPair.value, pa.funnelInterval.value]).toEqual([
"steps",
null,
"auto",
]);
const plain = pa.toQuery("productAnalyticsFunnelBuilder");
for (const k of ["fv", "fp", "fi"]) expect(plain).not.toHaveProperty(k);
});
it("ignores unknown fv, fp and fi values without flagging the link", () => {
const pa = useProductAnalytics();
pa.initFromRoute({ app: "web", fv: "chart", fp: "6-1", fi: "hour" });
expect([pa.funnelView.value, pa.funnelPair.value, pa.funnelInterval.value]).toEqual([
"steps",
null,
"auto",
]);
expect(pa.invalidParams.value).toEqual([]);
});
it("ignores an unknown fc value without flagging the link", () => {
const pa = useProductAnalytics();
for (const fc of ["week", "1m", "true", ""]) {
pa.initFromRoute({ app: "web", fc });
expect(pa.funnelCompare.value).toBeNull();
}
expect(pa.invalidParams.value).toEqual([]);
});
it("falls back to defaults and reports each invalid param once (D-45)", () => {
const pa = useProductAnalytics();
pa.initFromRoute({ app: "web", funnel: "%%%", depth: "99", rmode: "x", exact: "7" });
+41 -3
View File
@@ -40,6 +40,8 @@ import {
IDENTITY_FIELDS,
type AnalyticsScope,
type FunnelDef,
type FunnelPair,
type Granularity,
type IdentitySql,
type PathsDef,
type RetentionDef,
@@ -60,6 +62,7 @@ import {
parsePathsDef,
parseRetentionDef,
parseScope,
pinnedAfter,
resolveIdentity,
samplingRatio,
stepRefParam,
@@ -75,6 +78,12 @@ import {
type UaSuggestionRow,
} from "@/utils/rum/productAnalyticsModel";
import { PA_ROUTES, type AnalyticsSubTab } from "@/utils/rum/productAnalyticsRoutes";
import {
FUNNEL_COMPARE_MODES,
previousWindow,
type FunnelCompareMode,
} from "@/utils/rum/funnelCompare";
import { pairParam, parseInterval, parsePair, type FunnelView } from "@/utils/rum/funnelTrend";
export type { AnalyticsScopeState } from "@/utils/rum/productAnalyticsModel";
export type { AnalyticsSubTab } from "@/utils/rum/productAnalyticsRoutes";
@@ -154,6 +163,12 @@ const scopeError = ref<string | null>(null);
const invalidParams = ref<string[]>([]);
const lastSubTab = ref<AnalyticsSubTab>("overview");
const funnel = ref<FunnelDef>(defaultFunnel());
// Compare to the previous period lives in the link only, never in a saved funnel.
const funnelCompare = ref<FunnelCompareMode | null>(null);
// The trend view, its step pair and its interval are link state too; a null pair is step 1 → the last step.
const funnelView = ref<FunnelView>("steps");
const funnelPair = ref<FunnelPair | null>(null);
const funnelInterval = ref<Granularity>("auto");
const paths = ref<PathsDef>(defaultPaths());
const retention = ref<RetentionDef>(defaultRetention());
const trendSeries = ref<StepRef[]>([]);
@@ -236,6 +251,10 @@ export function resetProductAnalytics(): void {
invalidParams.value = [];
lastSubTab.value = "overview";
funnel.value = defaultFunnel();
funnelCompare.value = null;
funnelView.value = "steps";
funnelPair.value = null;
funnelInterval.value = "auto";
paths.value = defaultPaths();
retention.value = defaultRetention();
trendSeries.value = [];
@@ -415,6 +434,11 @@ export default function useProductAnalytics() {
retention.value = defaultRetention();
if (!keepFunnel) {
funnel.value = defaultFunnel();
const fc = firstString(q.fc);
funnelCompare.value = FUNNEL_COMPARE_MODES.find((m) => m === fc) ?? null;
funnelView.value = firstString(q.fv) === "trend" ? "trend" : "steps";
funnelPair.value = parsePair(firstString(q.fp));
funnelInterval.value = parseInterval(firstString(q.fi));
if (q.funnel !== undefined) {
const def = parseFunnelDef(decodeDef(firstString(q.funnel)));
if (def) funnel.value = def;
@@ -465,6 +489,10 @@ export default function useProductAnalytics() {
const sf = openedFunnel.value?.id ?? pendingSavedId.value;
if (routeName !== PA_ROUTES.funnels) {
if (funnel.value.steps.length) q.funnel = encodeDef(funnelParam(funnel.value));
if (funnelCompare.value) q.fc = funnelCompare.value;
if (funnelView.value === "trend") q.fv = "trend";
if (funnelPair.value) q.fp = pairParam(funnelPair.value);
if (funnelInterval.value !== "auto") q.fi = funnelInterval.value;
if (sf) q.sf = sf;
}
const p = paths.value;
@@ -639,7 +667,7 @@ export default function useProductAnalytics() {
};
const runSummary = async (cur: UsRange, key: string, stale: () => boolean) => {
const prev = { startUs: cur.startUs - (cur.endUs - cur.startUs), endUs: cur.endUs };
const prev = { startUs: previousWindow(cur).startUs, endUs: cur.endUs };
const [sum, tops] = await Promise.all([
search.run<Record<string, number>>(
"scope-summary",
@@ -784,7 +812,13 @@ export default function useProductAnalytics() {
// A saved funnel keeps a deleted event's step, tagged, so it stays editable.
const saved = !!openedFunnel.value || !!pendingSavedId.value;
if (!saved && f.steps.some(missing)) {
funnel.value = { ...f, steps: f.steps.filter((s) => !missing(s)) };
const pos = (k: number) =>
missing(f.steps[k - 1]) ? null : f.steps.slice(0, k).filter((s) => !missing(s)).length;
funnel.value = {
...f,
steps: f.steps.filter((s) => !missing(s)),
breakdown: pinnedAfter(f.breakdown, pos),
};
dropped = true;
}
const p = dropCohortSteps(paths.value, missing);
@@ -967,7 +1001,7 @@ export default function useProductAnalytics() {
const readEntryExit = async (key: string): Promise<Record<string, unknown>[]> => {
const cur = resolveRange();
const prev = { startUs: cur.startUs - (cur.endUs - cur.startUs), endUs: cur.endUs };
const prev = { startUs: previousWindow(cur).startUs, endUs: cur.endUs };
const res = await search.run<Record<string, unknown>>(
"scope-entry-exit",
{
@@ -1085,6 +1119,10 @@ export default function useProductAnalytics() {
invalidParams,
lastSubTab,
funnel,
funnelCompare,
funnelView,
funnelPair,
funnelInterval,
paths,
retention,
trendSeries,
@@ -7,6 +7,8 @@ export interface FieldItem {
type?: string;
isGroup?: boolean;
groupName?: string;
/** Marks the row unavailable to assistive tech; the owner still decides what a click does. */
disabled?: boolean;
[key: string]: any;
}
@@ -70,6 +70,7 @@
}"
:tabindex="keyboard ? 0 : undefined"
:role="keyboard ? 'button' : undefined"
:aria-disabled="row.disabled ? 'true' : undefined"
:data-test="`o-field-list-row-${row.name}`"
:draggable="draggable && isDragEnabled(row, row._index ?? 0) && isDragArmed()"
@click="(e: MouseEvent) => onRowClick(row, e)"
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "الجلسة التي قامت بذلك بين الخطوتين تتوقف عن الاحتساب عند الخطوة الثانية.",
"addExclusionStep": "إضافة خطوة استبعاد",
"railSteps": "الخطوات",
"railSettings": "الإعدادات",
"collapseRail": "طيّ اللوحة الجانبية",
"expandRail": "توسيع اللوحة الجانبية",
"addStep": "إضافة خطوة",
"railWindow": "النافذة",
"railBreakdown": "التقسيم",
"where": "حيث",
"alert": "تنبيه",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "تحتفظ اللوحة بهذه الخطوات وعوامل التصفية وقواعد الأحداث المسماة كما هي الآن، لكامل التطبيق دون بيئة هذه الصفحة أو إصدارها أو التطبيقات المقارنة. يستخدم عوامل تصفية هذا التطبيق كما هي الآن؛ أعد إنشاءها بعد تغييرها.",
"needsNewer": "يحتاج هذا القمع إلى إصدار أحدث",
"step": "خطوة",
"ofFirst": "من الخطوة 1",
"ofPrevious": "من السابق",
"ofPreviousTooltip": "نسبة من وصل من الخطوة السابقة إلى هذه الخطوة",
"ofFirst": "التحويل من الخطوة 1",
"ofPrevious": "التحويل من الخطوة السابقة",
"ofPreviousTooltip": "نسبة الوصول إلى هذه الخطوة من الخطوة السابقة",
"ofFirstTooltip": "نسبة من وصل من الخطوة 1 إلى هذه الخطوة. الجزء المظلل: محذوف بواسطة خطوات الاستبعاد",
"ofFirstCompareTooltip": "نسبة من وصل إلى هذه الخطوة من الخطوة 1. الشريط المصمت: هذه الفترة. الشريط المخطط: الفترة السابقة.",
"unitTooltip": "{unit} التي وصلت إلى هذه الخطوة",
"timeFromFirst": "الوقت من الخطوة 1",
"timeTooltip": "أقصر وقت بالترتيب من حدث الخطوة 1 إلى هذه الخطوة، لكل {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "انظر لماذا تركوا",
"droppedAria": "{count} متروك ({pct}) بعد الخطوة {step}، انظر السبب في لوحة جانبية",
"noneDropped": "0 متروك",
"droppedCompare": "{pct} انخفض ({count}) مقابل {prevPct} ({prevCount}) في الفترة السابقة",
"droppedCompareAria": "{pct} انخفض ({count}) مقابل {prevPct} ({prevCount}) في الفترة السابقة، بعد الخطوة {step}، اطّلع على السبب في لوحة جانبية",
"droppedCompareTooltip": "هذه الفترة، {range}: {pct} من {outOf} {unit}. الفترة السابقة، {prevRange}: {prevPct} من {prevOutOf} {unit}. يفتح تسرّب هذه الفترة.",
"compareTooLong": "تعمل المقارنة على نطاقات تصل إلى 31 يومًا. اختر نطاقًا أقصر للمقارنة.",
"compareTo": "مقارنة بـ",
"compareOff": "إيقاف",
"comparePrev": "الفترة السابقة",
"compareAgo1h": "قبل ساعة واحدة",
"compareAgo1d": "قبل يوم واحد",
"compareAgo1w": "قبل أسبوع واحد",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "يتداخل مع هذا النطاق",
"compareSameAsPrev": "نفس الفترة السابقة",
"compareNoLineAbout": "حول خط المقارنة المفقود",
"compareNoLineBreakdown": "لا يحتوي المخطط على خط مقارنة أثناء تفعيل التوزيع: كل قيمة من {field} لها خطها الخاص. لا يزال العنوان الرئيسي يقارن القمع بأكمله مع {range}.",
"thisPeriod": "هذه الفترة",
"changeHeader": "التغيير (نقاط)",
"changeHeaderTooltip": "التغيير بالنقاط المئوية، في التحويل من الخطوة السابقة.",
"changePts": "{arrow}{value} نقاط",
"lowVolume": "≈ حجم منخفض",
"changeTooltip": "فاصل الثقة 95%: ± {ci} نقاط. من أصل {cur} {unit} هذه الفترة و{prev} في الفترة السابقة.",
"previousFailed": "تعذّر تحميل الفترة السابقة",
"previousCount": "مقابل {count}",
"viewSteps": "الخطوات",
"viewTrend": "الاتجاه",
"trendPairOption": "الخطوة {a} → الخطوة {b}",
"trendFrom": "التحويل من",
"trendTo": "إلى",
"trendLastStep": "{step} (الخطوة الأخيرة)",
"trendFromHere": "عرض اتجاه التحويل من هذه الخطوة",
"trendInterval": "الفاصل الزمني",
"intervalAuto": "تلقائي",
"intervalAutoResolved": "تلقائي ({interval})",
"intervalDay": "يوم",
"intervalWeek": "أسبوع",
"intervalMonth": "شهر",
"intervalNameDay": "يوم",
"intervalNameWeek": "أسبوع",
"intervalNameMonth": "شهر",
"trendAxis": "التحويل (%)",
"trendNoUnits": "لا يوجد {unit}",
"trendNoDataBefore": "لا توجد بيانات قبل {date}",
"trendArriving": "البيانات لا تزال تصل",
"trendStillConverting": "لا يزال التحويل جاريًا",
"trendStillConvertingToday": "لا يزال التحويل جاريًا (اليوم)",
"trendStillConvertingTooltip": "بعض هذه {unit} قد يصل إلى الخطوة {step}، لذا قد ترتفع هذه النسبة.",
"trendLowVolume": "حجم منخفض",
"trendLowVolumeTooltip": "دخل أقل من {n} {unit}، لذا تتغير هذه النسبة كثيرًا مع كل واحد منها.",
"trendPartial": "جزئي",
"trendPartialTooltip": "يغطي النطاق جزءًا فقط من {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "دخل الخطوة {n}",
"trendReached": "وصل إلى الخطوة {n}",
"trendPrevSameDay": "الفترة السابقة، نفس اليوم",
"trendPrevSameWeek": "الفترة السابقة، نفس الأسبوع",
"trendPrevSameMonth": "الفترة السابقة، نفس الشهر",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · بلا {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "لا يحتوي هذا النطاق على {unit}",
"trendEmptyDescription": "لم يصل أحد إلى الخطوة {step} بين هذين التاريخين. جرّب نطاقًا أوسع.",
"trendTooShort": "اختر نطاقًا لا يقل عن يومين لعرض التحويل بمرور الوقت",
"trendTooMany": "اختر نطاقًا أقصر أو فاصلًا زمنيًا أطول لعرض التحويل بمرور الوقت",
"trendAverage": "متوسط التحويل",
"trendAverageTooltip": "يُجمع عبر كل {interval} اكتمل تحويله؛ وتُستبعد الفترات التي لا يزال التحويل جاريًا فيها.",
"trendTooltipRate": "{rate} ({reached} من {entered})",
"trendTooltipPrev": "الفترة السابقة، {date}: {rate}",
"alertTrend": "بدّل إلى الخطوات لتعيين تنبيه",
"overallVs": "مقابل {pct} في {range}",
"overallVsAgo": "مقابل {pct} {label} ({range})",
"compareEmpty": "لا يحتوي {range} على {unit} للمقارنة",
"breakdownPrevTooltip": "{last} من {first} {unit} في {range}",
"nextStep": "الخطوة التالية، الأكثر شيوعًا بعد الخطوة {step}:",
"searchStep": "ابحث في الصفحات والنقرات",
"searchStepAll": "ابحث في الصفحات والنقرات والأحداث والأخطاء",
@@ -2261,6 +2333,9 @@
"atLeast": "على الأقل",
"notSet": "(غير محدد)",
"other": "أخرى",
"leftBefore": "غادر قبل الخطوة {step}",
"reachedNoValue": "وصل إلى الخطوة {step}، بلا قيمة",
"breakdownFieldMissing": "{field} ليس حقلًا في بيانات هذا التطبيق، لذا تُعد جميع قيم {unit} غير مضبوطة.",
"total": "الإجمالي",
"conversion": "التحويل",
"usersFallback": "يحسب هذا الرابط المستخدمين، ولكن لا توجد هوية مستخدم يتم تحديدها لهذا التطبيق والنطاق، لذا فإنه يحسب الجلسات.",
@@ -2273,17 +2348,20 @@
"pickFirst": "أو اختر الخطوة 1 من الصفحات والنقرات",
"overall": "التحويل الإجمالي",
"overallOf": "{last} من {first} {unit}",
"overallTime": "· الوسيط {median} للتحويل (p90 {p90})",
"countBy": "العد بواسطة",
"sessions": "الجلسات",
"sessionNoun": "جلسات",
"windowSession": "نفس الجلسة",
"window1h": "خلال ساعة واحدة",
"window1d": "خلال يوم واحد",
"window7d": "خلال 7 أيام",
"noBreakdown": "بدون تفصيل",
"breakdownLabel": "التوزيع",
"breakdownNone": "لا شيء",
"windowSession": "الجلسة نفسها · زيارة واحدة",
"windowSessionShort": "الجلسة نفسها",
"window1h": "ساعة واحدة",
"window1d": "يوم واحد",
"window7d": "7 أيام",
"windowOfStep1": "{window} · من الخطوة 1",
"whoFinishIn": "الذين يُكملون الخطوات في",
"viewAs": "عرض كـ",
"headlineWithin": "خلال {window} من الخطوة 1",
"headlineMedian": "الوسيط {value} للتحويل",
"headlineEstimated": "تقدير",
"clearSteps": "مسح الخطوات",
"dims": {
"browser": "المتصفح",
@@ -2303,7 +2381,6 @@
"usersExact": "يعمل وضع المستخدمين بالحساب الدقيق وقد يكون بطيئًا في هذا النطاق.",
"step1EmptyTitle": "لم يصل أي {unit} إلى الخطوة 1 في هذا النطاق",
"step1EmptyDescription": "لم يصل أي أحد إلى {step} في هذا النطاق. وسّع النطاق، أو ابدأ من إحدى الصفحات الأعلى ترتيبًا بدلًا من ذلك.",
"breakdownTimeHidden": "يُعرض زمن التحويل للقمع بدون تقسيم.",
"alertMe": "نبّهني عندما ينخفض معدل التحويل عن قيمة اليوم",
"alertNeedsRun": "أنشئ قمعًا من خطوتين أو أكثر لتنبيه بشأنه",
"alertWaiting": "انتظر حتى ينتهي القمع من العدّ",
@@ -2371,7 +2448,64 @@
"filterRejected": "لا يمكن استخدام هذه القيم كعامل تصفية. طول كل قيمة من 1 إلى 1024 حرفًا، وبحد أقصى 20 قيمة.",
"whyEstimated": "تقديري: قد لا تتوافق هذه الأسطر تمامًا مع عدد مرات الدخول.",
"tooLongForLink": "تؤدي هذه الخطوة إلى جعل القمع طويلًا جدًا ليبقى في رابطه؛ اختصر خطوة أو أزلها أولًا",
"editTooLongForLink": "يجعل هذا التغيير القمع أطول من أن يبقى ضمن رابطه؛ أزل خطوة أو عامل تصفية أو استبعادًا أولًا"
"editTooLongForLink": "يجعل هذا التغيير القمع أطول من أن يبقى ضمن رابطه؛ أزل خطوة أو عامل تصفية أو استبعادًا أولًا",
"breakdownRemove": "إزالة التقسيم",
"breakdownAdd": "إضافة تقسيم",
"breakdownCaption": "قسّم كل خطوة حسب حقل واحد، مثل المتصفح أو طريقة الدفع",
"breakdownOneAtATime": "تقسيم واحد في كل مرة",
"breakdownReplace": "استبدل التقسيم بـ {field}",
"breakdownValueFrom": "القيمة من",
"pickerOnSteps": "على هذه الخطوات",
"pickerKeyFields": "الحقول الأساسية",
"pickerUser": "المستخدم",
"pickerAllFields": "كل الحقول",
"pickerStepHint": "الخطوة {step} · {values}",
"pickerStepHintMore": "الخطوة {step} · {values} +{more}",
"pickerManyValues": "الخطوة {step} · قيم كثيرة",
"pickerUserReason": "تُعرض حقول المستخدم للفلاتر فقط",
"pickerSampling": "جارٍ قراءة الحقول في خطوات هذا القمع…",
"pickerSampleFailed": "تعذّرت قراءة الحقول في خطوات هذا القمع",
"fromStep": "من الخطوة {step}",
"fromAnyStep": "من أي خطوة · {pct} مُعيَّنة",
"fromAutomatic": "تلقائي (الخطوة {step})",
"fromAutomaticNone": "تلقائي",
"fromStepItem": "الخطوة {step} · {name}",
"stepMenu": "إجراءات الخطوة",
"breakDownByStep": "التفصيل حسب خاصية في هذه الخطوة",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "قيم كثيرة",
"breakdownBy": "حسب {field}",
"headlineBreakdown": "حسب {field}",
"breakdownAbout": "حول هذا التفصيل",
"breakdownAboutAuto": "يُعيَّن {field} أول مرة في الخطوة {step}، {name}، في {covered} من {reached} {unit} التي وصلت إليها، لذا تبدأ معدلاته من الخطوة {step}.",
"breakdownAboutPinned": "تُقرأ {field} في الخطوة {step}، {name}، الخطوة المختارة لها؛ {covered} من {reached} {unit} التي وصلت إليها تحمل قيمة، لذا تبدأ معدلاته من الخطوة {step}.",
"breakdownAboutAny": "يُعيَّن {field} على {covered} من {reached} {unit}، ولا يُعيَّن في أي خطوة لنصف أولئك الذين يصلون إليها، لذا تبدأ معدلاته من الخطوة 1.",
"breakdownAboutLeft": "أولئك الذين غادروا قبل الخطوة {step} ليست لديهم قيمة ويُحتسبون في صف واحد.",
"conversionFromStep": "التحويل من الخطوة {step}",
"medianTime": "الوقت الوسيط",
"lowVolumeTag": "حجم منخفض",
"lowVolumeNote": "تُوسم القيم التي تقل عن {count} {unit} في الخطوة {step} بأنها حجم منخفض",
"propertyTitle": "الخاصية",
"propertyField": "الحقل",
"propertyAddValue": "القيمة",
"propertyAdd": "إضافة خاصية",
"propertyRemove": "إزالة خاصية",
"propertyInvalid": "نمط غير صالح",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "لم تظهر أي حقول في هذه الخطوة",
"propertyFieldsLoading": "جارٍ قراءة حقول هذه الخطوة…",
"propertyFieldsFailed": "تعذّرت قراءة حقول هذه الخطوة",
"propConditions": {
"in": "هو",
"notIn": "ليس",
"contains": "يحتوي",
"regex": "يطابق"
},
"filterNotice": "يُبقي {kept} من {opened} {unit}.",
"filterNoticeAction": "التفصيل حسب {field}",
"filterNoticeWhy": "لماذا تعرض كل خطوة 100%",
"filterNoticeReason": "يحتفظ فلتر كل الخطوات بـ {unit} بأكملها أو يُسقطها. {missing} من {opened} {unit} التي فتحت الخطوة 1 لم تُعيّن {field} قط، لذا تُسقط قبل الخطوة 1 ويمكن للخطوات المبكرة أن تعرض 100%.",
"pickerOnStep": "في الخطوة {step}"
},
"viewer": {
"droppedAfter": "تسرّب بعد الخطوة {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "القمع: {first} → {last}",
"funnelTrendTitle": "التحويل عبر الوقت: {first} → {last}",
"step": "خطوة",
"add": "إضافة إلى لوحة معلومات",
"funnelOnly": "تدعم لوحات المعلومات أقماع الجلسات بدون تفصيل",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Eine Sitzung, die dies zwischen den beiden Schritten getan hat, wird beim zweiten Schritt nicht mehr gezählt.",
"addExclusionStep": "Ausschlussschritt hinzufügen",
"railSteps": "Schritte",
"railSettings": "Einstellungen",
"collapseRail": "Seitenleiste einklappen",
"expandRail": "Seitenleiste ausklappen",
"addStep": "Schritt hinzufügen",
"railWindow": "Fenster",
"railBreakdown": "Aufschlüsselung",
"where": "wobei",
"alert": "Alarm",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Das Panel behält diese Schritte, Filter und Regeln für benannte Ereignisse so bei, wie sie jetzt sind, für die gesamte App ohne die Umgebung, Version oder Vergleichs-Apps dieser Seite. Verwendet die App-Filter dieser App in ihrem aktuellen Stand; erstellen Sie es neu, nachdem sie sich ändern.",
"needsNewer": "Dieser Funnel benötigt eine neuere Version",
"step": "Schritt",
"ofFirst": "Von Schritt 1",
"ofPrevious": "Vom vorherigen Schritt",
"ofPreviousTooltip": "Anteil des vorherigen Schritts, der diesen erreicht hat",
"ofFirst": "Konversion ab Schritt 1",
"ofPrevious": "Konversion aus dem vorherigen Schritt",
"ofPreviousTooltip": "Anteil des vorherigen Schritts, der diesen Schritt erreicht hat",
"ofFirstTooltip": "Anteil von Schritt 1, der diesen Schritt erreicht hat. Schraffierter Teil: durch Ausschlussschritte entfernt",
"ofFirstCompareTooltip": "Anteil von Schritt 1, der diesen Schritt erreicht hat. Durchgezogener Balken: dieser Zeitraum. Schraffierter Balken: vorheriger Zeitraum.",
"unitTooltip": "{unit}, die diesen Schritt erreicht haben",
"timeFromFirst": "Zeit ab Schritt 1",
"timeTooltip": "Kürzeste Zeit in Reihenfolge von einem Ereignis aus Schritt 1 bis zu diesem Schritt, pro {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Gründe für den Abbruch ansehen",
"droppedAria": "{count} abgesprungen ({pct}) nach Schritt {step}, Gründe in einem Seitenbereich ansehen",
"noneDropped": "0 abgesprungen",
"droppedCompare": "{pct} Abbruch ({count}) vs. {prevPct} ({prevCount}) im vorherigen Zeitraum",
"droppedCompareAria": "{pct} Abbruch ({count}) vs. {prevPct} ({prevCount}) im vorherigen Zeitraum, nach Schritt {step}; Gründe im Seitenbereich ansehen",
"droppedCompareTooltip": "Dieser Zeitraum, {range}: {pct} von {outOf} {unit}. Vorheriger Zeitraum, {prevRange}: {prevPct} von {prevOutOf} {unit}. Öffnet den Abbruch dieses Zeitraums.",
"compareTooLong": "Vergleichen funktioniert für Zeiträume bis zu 31 Tagen. Wählen Sie einen kürzeren Zeitraum zum Vergleichen.",
"compareTo": "Vergleichen mit",
"compareOff": "Aus",
"comparePrev": "Vorheriger Zeitraum",
"compareAgo1h": "Vor 1 Stunde",
"compareAgo1d": "Vor 1 Tag",
"compareAgo1w": "Vor 1 Woche",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "überschneidet sich mit diesem Zeitraum",
"compareSameAsPrev": "entspricht dem vorherigen Zeitraum",
"compareNoLineAbout": "Über die fehlende Vergleichslinie",
"compareNoLineBreakdown": "Das Diagramm hat keine Vergleichslinie, solange eine Aufschlüsselung aktiv ist: jeder Wert von {field} ist eine eigene Linie. Die Kopfzeile vergleicht weiterhin den gesamten Funnel mit {range}.",
"thisPeriod": "Dieser Zeitraum",
"changeHeader": "Änderung (Pp.)",
"changeHeaderTooltip": "Änderung in Prozentpunkten bei der Konversion aus dem vorherigen Schritt.",
"changePts": "{arrow}{value} Pp.",
"lowVolume": "≈ geringes Volumen",
"changeTooltip": "95%-Intervall: ± {ci} Pp. Von {cur} {unit} in diesem Zeitraum und {prev} im vorherigen Zeitraum.",
"previousFailed": "Der vorherige Zeitraum konnte nicht geladen werden",
"previousCount": "vs. {count}",
"viewSteps": "Schritte",
"viewTrend": "Trend",
"trendPairOption": "Schritt {a} → Schritt {b}",
"trendFrom": "Konversion von",
"trendTo": "bis",
"trendLastStep": "{step} (letzter Schritt)",
"trendFromHere": "Konversionstrend ab diesem Schritt anzeigen",
"trendInterval": "Intervall",
"intervalAuto": "Automatisch",
"intervalAutoResolved": "Automatisch ({interval})",
"intervalDay": "Tag",
"intervalWeek": "Woche",
"intervalMonth": "Monat",
"intervalNameDay": "Tag",
"intervalNameWeek": "Woche",
"intervalNameMonth": "Monat",
"trendAxis": "Konversion (%)",
"trendNoUnits": "Keine {unit}",
"trendNoDataBefore": "Keine Daten vor {date}",
"trendArriving": "Daten treffen noch ein",
"trendStillConverting": "Konvertiert noch",
"trendStillConvertingToday": "Konvertiert noch (heute)",
"trendStillConvertingTooltip": "Einige dieser {unit} können Schritt {step} noch erreichen, daher kann diese Rate steigen.",
"trendLowVolume": "Geringes Volumen",
"trendLowVolumeTooltip": "Weniger als {n} {unit} sind eingestiegen, daher schwankt diese Rate stark.",
"trendPartial": "Teilweise",
"trendPartialTooltip": "Der Zeitraum deckt nur einen Teil dieses {interval} ab.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Schritt {n} betreten",
"trendReached": "Schritt {n} erreicht",
"trendPrevSameDay": "Vorheriger Zeitraum, gleicher Tag",
"trendPrevSameWeek": "Vorheriger Zeitraum, gleiche Woche",
"trendPrevSameMonth": "Vorheriger Zeitraum, gleicher Monat",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · ohne {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Keine {unit} in diesem Zeitraum",
"trendEmptyDescription": "Niemand hat zwischen diesen Daten Schritt {step} erreicht. Wählen Sie einen breiteren Zeitraum.",
"trendTooShort": "Wählen Sie einen Zeitraum von mindestens 2 Tagen, um die Conversion im Zeitverlauf zu sehen",
"trendTooMany": "Wählen Sie einen kürzeren Zeitraum oder ein längeres Intervall, um die Conversion im Zeitverlauf zu sehen",
"trendAverage": "Durchschnittliche Conversion",
"trendAverageTooltip": "Summiert über {interval} mit abgeschlossener Conversion; noch laufende bleiben außen vor.",
"trendTooltipRate": "{rate} ({reached} von {entered})",
"trendTooltipPrev": "Vorheriger Zeitraum, {date}: {rate}",
"alertTrend": "Zur Ansicht „Schritte“ wechseln, um einen Alarm einzurichten",
"overallVs": "vs. {pct} in {range}",
"overallVsAgo": "vs. {pct} {label} ({range})",
"compareEmpty": "Keine {unit} in {range} zum Vergleichen",
"breakdownPrevTooltip": "{last} von {first} {unit} in {range}",
"nextStep": "Nächster Schritt, am häufigsten nach Schritt {step}:",
"searchStep": "Seiten und Klicks suchen",
"searchStepAll": "Seiten, Klicks, Ereignisse und Fehler suchen",
@@ -2261,6 +2333,9 @@
"atLeast": "mindestens",
"notSet": "(nicht festgelegt)",
"other": "Sonstige",
"leftBefore": "Vor Schritt {step} abgesprungen",
"reachedNoValue": "Schritt {step} erreicht, kein Wert",
"breakdownFieldMissing": "{field} ist kein Feld in den Daten dieser App, daher gelten alle {unit} als nicht gesetzt.",
"total": "Gesamt",
"conversion": "Konversion",
"usersFallback": "Dieser Link zählt Benutzer, aber für diese App und diesen Zeitraum lässt sich keine Benutzeridentität auflösen, daher werden Sitzungen gezählt.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Oder Schritt 1 aus Seiten und Klicks wählen",
"overall": "Gesamtkonversion",
"overallOf": "{last} von {first} {unit}",
"overallTime": "· Median {median} bis zur Konversion (p90 {p90})",
"countBy": "Zählen nach",
"sessions": "Sitzungen",
"sessionNoun": "Sitzungen",
"windowSession": "Gleiche Sitzung",
"window1h": "Innerhalb von 1 Stunde",
"window1d": "Innerhalb von 1 Tag",
"window7d": "Innerhalb von 7 Tagen",
"noBreakdown": "Keine Aufschlüsselung",
"breakdownLabel": "Aufschlüsselung",
"breakdownNone": "Keine",
"windowSession": "dieselbe Sitzung · ein Besuch",
"windowSessionShort": "dieselbe Sitzung",
"window1h": "1 Stunde",
"window1d": "1 Tag",
"window7d": "7 Tage",
"windowOfStep1": "{window} · ab Schritt 1",
"whoFinishIn": "die Schritte abschließen in",
"viewAs": "Anzeigen als",
"headlineWithin": "innerhalb von {window} nach Schritt 1",
"headlineMedian": "Median {value} bis zur Conversion",
"headlineEstimated": "gesch.",
"clearSteps": "Schritte löschen",
"dims": {
"browser": "Browser",
@@ -2303,7 +2381,6 @@
"usersExact": "Der Benutzermodus läuft exakt und kann in diesem Bereich langsam sein.",
"step1EmptyTitle": "Keine {unit} haben Schritt 1 in diesem Bereich erreicht",
"step1EmptyDescription": "Niemand hat {step} in diesem Bereich erreicht. Erweitern Sie den Bereich, oder starten Sie stattdessen von einer Top-Seite.",
"breakdownTimeHidden": "Die Zeit bis zur Konversion wird für den Funnel ohne Aufschlüsselung angezeigt.",
"alertMe": "Mich benachrichtigen, wenn die Konversion unter den heutigen Wert fällt",
"alertNeedsRun": "Erstellen Sie einen Funnel mit zwei oder mehr Schritten, um einen Alarm dafür einzurichten",
"alertWaiting": "Warten Sie, bis der Funnel die Zählung abgeschlossen hat",
@@ -2371,7 +2448,64 @@
"filterRejected": "Diese Werte können nicht als Filter verwendet werden. Jeder Wert hat 1 bis 1024 Zeichen, maximal 20.",
"whyEstimated": "Geschätzt: Diese Zeilen ergeben möglicherweise nicht genau die Anzahl der Einstiege.",
"tooLongForLink": "Dieser Schritt macht den Funnel zu lang, um ihn im Link zu behalten; kürzen oder entfernen Sie zuerst einen Schritt",
"editTooLongForLink": "Durch diese Änderung wird der Funnel zu lang für seinen Link; entfernen Sie zuerst einen Schritt, einen Filter oder einen Ausschluss."
"editTooLongForLink": "Durch diese Änderung wird der Funnel zu lang für seinen Link; entfernen Sie zuerst einen Schritt, einen Filter oder einen Ausschluss.",
"breakdownRemove": "Aufschlüsselung entfernen",
"breakdownAdd": "Aufschlüsselung hinzufügen",
"breakdownCaption": "Jeden Schritt nach einem Feld aufteilen, z. B. Browser oder Zahlungsmethode",
"breakdownOneAtATime": "Nur eine Aufschlüsselung gleichzeitig",
"breakdownReplace": "Aufschlüsselung durch {field} ersetzen",
"breakdownValueFrom": "Wert aus",
"pickerOnSteps": "Bei diesen Schritten",
"pickerKeyFields": "Schlüsselfelder",
"pickerUser": "Benutzer",
"pickerAllFields": "Alle Felder",
"pickerStepHint": "Schritt {step} · {values}",
"pickerStepHintMore": "Schritt {step} · {values} +{more}",
"pickerManyValues": "Schritt {step} · viele Werte",
"pickerUserReason": "Benutzerfelder werden nur für Filter angeboten",
"pickerSampling": "Felder auf den Schritten dieses Trichters werden gelesen…",
"pickerSampleFailed": "Felder auf den Schritten dieses Trichters konnten nicht gelesen werden",
"fromStep": "Ab Schritt {step}",
"fromAnyStep": "Ab jedem Schritt · {pct} gesetzt",
"fromAutomatic": "Automatisch (Schritt {step})",
"fromAutomaticNone": "Automatisch",
"fromStepItem": "Schritt {step} · {name}",
"stepMenu": "Schritt-Aktionen",
"breakDownByStep": "Nach einer Eigenschaft dieses Schritts aufschlüsseln",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Viele Werte",
"breakdownBy": "Nach {field}",
"headlineBreakdown": "nach {field}",
"breakdownAbout": "Über diese Aufschlüsselung",
"breakdownAboutAuto": "{field} wird erstmals bei Schritt {step}, {name}, gesetzt: in {covered} der {reached} {unit}, die ihn erreichten, sodass seine Raten bei Schritt {step} beginnen.",
"breakdownAboutPinned": "{field} wird bei Schritt {step}, {name}, gelesen – dem dafür gewählten Schritt; {covered} der {reached} {unit}, die ihn erreichten, führen einen Wert, sodass seine Raten bei Schritt {step} beginnen.",
"breakdownAboutAny": "{field} ist bei {covered} der {reached} {unit} gesetzt, und bei der Hälfte derer, die ihn erreichen, auf keinem Schritt, sodass seine Raten bei Schritt 1 beginnen.",
"breakdownAboutLeft": "Diejenigen, die vor Schritt {step} ausgestiegen sind, haben keinen Wert und werden in einer Zeile gezählt.",
"conversionFromStep": "Konversion ab Schritt {step}",
"medianTime": "Medianzeit",
"lowVolumeTag": "Geringes Volumen",
"lowVolumeNote": "Werte unter {count} {unit} bei Schritt {step} werden als geringes Volumen markiert",
"propertyTitle": "Eigenschaft",
"propertyField": "Feld",
"propertyAddValue": "Wert",
"propertyAdd": "Eigenschaft hinzufügen",
"propertyRemove": "Eigenschaft entfernen",
"propertyInvalid": "Kein gültiges Muster",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Auf diesem Schritt wurden keine Felder gefunden",
"propertyFieldsLoading": "Felder dieses Schritts werden gelesen…",
"propertyFieldsFailed": "Felder dieses Schritts konnten nicht gelesen werden",
"propConditions": {
"in": "ist",
"notIn": "ist nicht",
"contains": "enthält",
"regex": "entspricht"
},
"filterNotice": "Behält {kept} von {opened} {unit}.",
"filterNoticeAction": "Nach {field} aufschlüsseln",
"filterNoticeWhy": "Warum jeder Schritt 100 % anzeigt",
"filterNoticeReason": "Filter auf alle Schritte behält oder verwirft ganze {unit}. {missing} der {opened} {unit}, die Schritt 1 geöffnet haben, haben {field} nie gesetzt, daher werden sie vor Schritt 1 verworfen und die frühen Schritte können 100 % anzeigen.",
"pickerOnStep": "Bei Schritt {step}"
},
"viewer": {
"droppedAfter": "Abgesprungen nach Schritt {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Funnel: {first} → {last}",
"funnelTrendTitle": "Konversion im Zeitverlauf: {first} → {last}",
"step": "Schritt",
"add": "Zum Dashboard hinzufügen",
"funnelOnly": "Dashboards unterstützen Sessions-Funnels ohne Aufschlüsselung",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "A session that did this between the two steps stops counting at the second step.",
"addExclusionStep": "Add exclusion step",
"railSteps": "Steps",
"railSettings": "Settings",
"collapseRail": "Collapse side panel",
"expandRail": "Expand side panel",
"addStep": "Add step",
"railWindow": "Window",
"railBreakdown": "Breakdown",
"where": "where",
"alert": "Alert",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "The panel keeps these steps, filters and named-event rules as they are now, for the whole app without this page's env, version or compared apps. Uses this app's app filters as of now; re-create it after they change.",
"needsNewer": "This funnel needs a newer version",
"step": "Step",
"ofFirst": "Of step 1",
"ofPrevious": "From previous",
"ofPreviousTooltip": "Share of the previous step that reached this one",
"ofFirst": "Conversion from step 1",
"ofPrevious": "Conversion from previous step",
"ofPreviousTooltip": "Share of the step above that reached this step",
"ofFirstTooltip": "Share of step 1 that reached this step. Hatched part: removed by exclusion steps",
"ofFirstCompareTooltip": "Share of step 1 that reached this step. Solid bar: this period. Hatched bar: previous period.",
"unitTooltip": "{unit} that reached this step",
"timeFromFirst": "Time from step 1",
"timeTooltip": "Shortest in-order time from a step-1 event to this step, per {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "See why they dropped",
"droppedAria": "{count} dropped ({pct}) after step {step}, see why in a side panel",
"noneDropped": "0 dropped",
"droppedCompare": "{pct} dropped ({count}) vs {prevPct} ({prevCount}) previous period",
"droppedCompareAria": "{pct} dropped ({count}) vs {prevPct} ({prevCount}) previous period, after step {step}, see why in a side panel",
"droppedCompareTooltip": "This period, {range}: {pct} of {outOf} {unit}. Previous period, {prevRange}: {prevPct} of {prevOutOf} {unit}. Opens this period's drop-off.",
"compareTooLong": "Compare works on ranges up to 31 days. Pick a shorter range to compare.",
"compareTo": "Compare to",
"compareOff": "Off",
"comparePrev": "Previous period",
"compareAgo1h": "1 hour ago",
"compareAgo1d": "1 day ago",
"compareAgo1w": "1 week ago",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "overlaps this range",
"compareSameAsPrev": "same as previous period",
"compareNoLineAbout": "About the missing comparison line",
"compareNoLineBreakdown": "The chart has no comparison line while a breakdown is on: each value of {field} is its own line. The headline still compares the whole funnel with {range}.",
"thisPeriod": "This period",
"changeHeader": "Change (pts)",
"changeHeaderTooltip": "Change in percentage points, in conversion from the step above.",
"changePts": "{arrow}{value} pts",
"lowVolume": "≈ low volume",
"changeTooltip": "95% interval: ± {ci} pts. Out of {cur} {unit} this period and {prev} previous period.",
"previousFailed": "The previous period could not be loaded",
"previousCount": "vs {count}",
"viewSteps": "Steps",
"viewTrend": "Trend",
"trendPairOption": "Step {a} → Step {b}",
"trendFrom": "Conversion from",
"trendTo": "to",
"trendLastStep": "{step} (last step)",
"trendFromHere": "Show conversion trend from this step",
"trendInterval": "Interval",
"intervalAuto": "Auto",
"intervalAutoResolved": "Auto ({interval})",
"intervalDay": "Day",
"intervalWeek": "Week",
"intervalMonth": "Month",
"intervalNameDay": "day",
"intervalNameWeek": "week",
"intervalNameMonth": "month",
"trendAxis": "Conversion (%)",
"trendNoUnits": "No {unit}",
"trendNoDataBefore": "No data before {date}",
"trendArriving": "Data still arriving",
"trendStillConverting": "Still converting",
"trendStillConvertingToday": "Still converting (today)",
"trendStillConvertingTooltip": "Some of these {unit} can still reach step {step}, so this rate may rise.",
"trendLowVolume": "Low volume",
"trendLowVolumeTooltip": "Fewer than {n} {unit} entered, so this rate moves a lot with each one.",
"trendPartial": "Partial",
"trendPartialTooltip": "The range covers only part of this {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Entered step {n}",
"trendReached": "Reached step {n}",
"trendPrevSameDay": "Previous period, same day",
"trendPrevSameWeek": "Previous period, same week",
"trendPrevSameMonth": "Previous period, same month",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · no {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "No {unit} in this range",
"trendEmptyDescription": "Nobody reached step {step} between these dates. Try a wider range.",
"trendTooShort": "Pick a range of at least 2 days to see conversion over time",
"trendTooMany": "Pick a shorter range or a longer interval to see conversion over time",
"trendAverage": "Average conversion",
"trendAverageTooltip": "Summed over every {interval} whose conversion has closed; still-converting ones are left out.",
"trendTooltipRate": "{rate} ({reached} of {entered})",
"trendTooltipPrev": "Previous period, {date}: {rate}",
"alertTrend": "Switch to Steps to set an alert",
"overallVs": "vs {pct} in {range}",
"overallVsAgo": "vs {pct} {label} ({range})",
"compareEmpty": "No {unit} in {range} to compare with",
"breakdownPrevTooltip": "{last} of {first} {unit} in {range}",
"nextStep": "Next step, most common after step {step}:",
"searchStep": "Search pages and clicks",
"searchStepAll": "Search pages, clicks, events and errors",
@@ -2261,6 +2333,9 @@
"atLeast": "at least",
"notSet": "(not set)",
"other": "Other",
"leftBefore": "Left before step {step}",
"reachedNoValue": "Reached step {step}, no value",
"breakdownFieldMissing": "{field} is not a field in this app's data, so all {unit} read as not set.",
"total": "Total",
"conversion": "Conversion",
"usersFallback": "This link counts users, but no user identity resolves for this app and range, so it counts sessions.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Or pick step 1 from pages and clicks",
"overall": "Overall conversion",
"overallOf": "{last} of {first} {unit}",
"overallTime": "· median {median} to convert (p90 {p90})",
"countBy": "Count by",
"sessions": "Sessions",
"sessionNoun": "sessions",
"windowSession": "Same session",
"window1h": "Within 1 hour",
"window1d": "Within 1 day",
"window7d": "Within 7 days",
"noBreakdown": "No breakdown",
"breakdownLabel": "Breakdown",
"breakdownNone": "None",
"windowSession": "the same session · one visit",
"windowSessionShort": "the same session",
"window1h": "1 hour",
"window1d": "1 day",
"window7d": "7 days",
"windowOfStep1": "{window} · of step 1",
"whoFinishIn": "who finish steps in",
"viewAs": "View as",
"headlineWithin": "within {window} of step 1",
"headlineMedian": "median {value} to convert",
"headlineEstimated": "est.",
"clearSteps": "Clear steps",
"dims": {
"browser": "Browser",
@@ -2303,7 +2381,6 @@
"usersExact": "Users mode runs exact and may be slow on this range.",
"step1EmptyTitle": "No {unit} reached step 1 in this range",
"step1EmptyDescription": "Nobody reached {step} in this range. Widen the range, or start from a top page instead.",
"breakdownTimeHidden": "Time to convert is shown for the funnel without a breakdown.",
"alertMe": "Alert me when conversion drops below today's",
"alertNeedsRun": "Build a funnel of two or more steps to alert on it",
"alertWaiting": "Wait for the funnel to finish counting",
@@ -2371,7 +2448,64 @@
"filterRejected": "These values cannot be used as a filter. Each value is 1 to 1024 characters, at most 20.",
"whyEstimated": "Estimated: these lines may not add up exactly to the entry count.",
"tooLongForLink": "This step makes the funnel too long to keep in its link; shorten or remove a step first",
"editTooLongForLink": "This change makes the funnel too long to keep in its link; remove a step, filter or exclusion first"
"editTooLongForLink": "This change makes the funnel too long to keep in its link; remove a step, filter or exclusion first",
"breakdownRemove": "Remove breakdown",
"breakdownAdd": "Add breakdown",
"breakdownCaption": "Split every step by one field, such as browser or payment method",
"breakdownOneAtATime": "One breakdown at a time",
"breakdownReplace": "Replace breakdown with {field}",
"breakdownValueFrom": "Value from",
"pickerOnSteps": "On these steps",
"pickerKeyFields": "Key fields",
"pickerUser": "User",
"pickerAllFields": "All fields",
"pickerStepHint": "Step {step} · {values}",
"pickerStepHintMore": "Step {step} · {values} +{more}",
"pickerManyValues": "Step {step} · many values",
"pickerUserReason": "User fields are offered for filters only",
"pickerSampling": "Reading the fields on this funnel's steps…",
"pickerSampleFailed": "Could not read the fields on this funnel's steps",
"fromStep": "From step {step}",
"fromAnyStep": "From any step · {pct} set",
"fromAutomatic": "Automatic (step {step})",
"fromAutomaticNone": "Automatic",
"fromStepItem": "Step {step} · {name}",
"stepMenu": "Step actions",
"breakDownByStep": "Break down by a property of this step",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Many values",
"breakdownBy": "By {field}",
"headlineBreakdown": "by {field}",
"breakdownAbout": "About this breakdown",
"breakdownAboutAuto": "{field} is first set at step {step}, {name}, in {covered} of the {reached} {unit} that reached it, so its rates start at step {step}.",
"breakdownAboutPinned": "{field} is read at step {step}, {name}, the step picked for it; {covered} of the {reached} {unit} that reached it carry a value, so its rates start at step {step}.",
"breakdownAboutAny": "{field} is set on {covered} of the {reached} {unit}, and on no step for half of those reaching it, so its rates start at step 1.",
"breakdownAboutLeft": "Those that left before step {step} have no value and are counted in one row.",
"conversionFromStep": "Conversion from step {step}",
"medianTime": "Median time",
"lowVolumeTag": "Low volume",
"lowVolumeNote": "Values below {count} {unit} at step {step} are marked low volume",
"propertyTitle": "Property",
"propertyField": "Field",
"propertyAddValue": "Value",
"propertyAdd": "Add property",
"propertyRemove": "Remove property",
"propertyInvalid": "Not a valid pattern",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "No fields seen on this step",
"propertyFieldsLoading": "Reading this step's fields…",
"propertyFieldsFailed": "Could not read this step's fields",
"propConditions": {
"in": "is",
"notIn": "is not",
"contains": "contains",
"regex": "matches"
},
"filterNotice": "Keeps {kept} of {opened} {unit}.",
"filterNoticeAction": "Break down by {field}",
"filterNoticeWhy": "Why every step shows 100%",
"filterNoticeReason": "Filter all steps keeps or drops whole {unit}. {missing} of the {opened} {unit} that opened step 1 never set {field}, so they are dropped before step 1 and the early steps can read 100%.",
"pickerOnStep": "On step {step}"
},
"viewer": {
"droppedAfter": "Dropped after step {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Funnel: {first} → {last}",
"funnelTrendTitle": "Conversion over time: {first} → {last}",
"step": "Step",
"add": "Add to dashboard",
"funnelOnly": "Dashboards support Sessions funnels without breakdown",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Una sesión que hizo esto entre los dos pasos deja de contarse en el segundo paso.",
"addExclusionStep": "Añadir paso de exclusión",
"railSteps": "Pasos",
"railSettings": "Ajustes",
"collapseRail": "Contraer el panel lateral",
"expandRail": "Expandir el panel lateral",
"addStep": "Añadir paso",
"railWindow": "Ventana",
"railBreakdown": "Desglose",
"where": "donde",
"alert": "Alerta",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "El panel conserva estos pasos, filtros y reglas de eventos con nombre tal como están ahora, para toda la aplicación sin el entorno, la versión ni las aplicaciones comparadas de esta página. Usa los filtros de aplicación de esta app tal como están ahora; vuelve a crearlo después de que cambien.",
"needsNewer": "Este embudo necesita una versión más reciente",
"step": "Paso",
"ofFirst": "Del paso 1",
"ofPrevious": "Del anterior",
"ofPreviousTooltip": "Proporción del paso anterior que llegó a este",
"ofFirst": "Conversión desde el paso 1",
"ofPrevious": "Conversión desde el paso anterior",
"ofPreviousTooltip": "Proporción del paso anterior que alcanzó este paso",
"ofFirstTooltip": "Proporción del paso 1 que llegó a este paso. Parte rayada: eliminada por los pasos de exclusión",
"ofFirstCompareTooltip": "Proporción del paso 1 que alcanzó este paso. Barra sólida: este período. Barra rayada: período anterior.",
"unitTooltip": "{unit} que llegó a este paso",
"timeFromFirst": "Tiempo desde el paso 1",
"timeTooltip": "Tiempo más corto en orden desde un evento del paso 1 hasta este paso, por {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Ver por qué abandonaron",
"droppedAria": "{count} abandonaron ({pct}) después del paso {step}, ver por qué en un panel lateral",
"noneDropped": "0 abandonaron",
"droppedCompare": "{pct} abandonaron ({count}) frente a {prevPct} ({prevCount}) del período anterior",
"droppedCompareAria": "{pct} abandonaron ({count}) frente a {prevPct} ({prevCount}) del período anterior, tras el paso {step}, consulta el motivo en un panel lateral",
"droppedCompareTooltip": "Este período, {range}: {pct} de {outOf} {unit}. Período anterior, {prevRange}: {prevPct} de {prevOutOf} {unit}. Abre el abandono de este período.",
"compareTooLong": "La comparación funciona en rangos de hasta 31 días. Elige un rango más corto para comparar.",
"compareTo": "Comparar con",
"compareOff": "Desactivado",
"comparePrev": "Período anterior",
"compareAgo1h": "Hace 1 hora",
"compareAgo1d": "Hace 1 día",
"compareAgo1w": "Hace 1 semana",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "se solapa con este rango",
"compareSameAsPrev": "igual que el período anterior",
"compareNoLineAbout": "Acerca de la línea de comparación ausente",
"compareNoLineBreakdown": "El gráfico no muestra la línea de comparación mientras hay un desglose activo: cada valor de {field} es su propia línea. El titular sigue comparando el embudo completo con {range}.",
"thisPeriod": "Este período",
"changeHeader": "Cambio (pts)",
"changeHeaderTooltip": "Cambio en puntos porcentuales, en la conversión desde el paso anterior.",
"changePts": "{arrow}{value} pts",
"lowVolume": "≈ volumen bajo",
"changeTooltip": "Intervalo del 95 %: ± {ci} pts. De {cur} {unit} este período y {prev} el período anterior.",
"previousFailed": "No se pudo cargar el período anterior",
"previousCount": "frente a {count}",
"viewSteps": "Pasos",
"viewTrend": "Tendencia",
"trendPairOption": "Paso {a} → Paso {b}",
"trendFrom": "Conversión desde",
"trendTo": "hasta",
"trendLastStep": "{step} (último paso)",
"trendFromHere": "Mostrar la tendencia de conversión desde este paso",
"trendInterval": "Intervalo",
"intervalAuto": "Automático",
"intervalAutoResolved": "Automático ({interval})",
"intervalDay": "Día",
"intervalWeek": "Semana",
"intervalMonth": "Mes",
"intervalNameDay": "día",
"intervalNameWeek": "semana",
"intervalNameMonth": "mes",
"trendAxis": "Conversión (%)",
"trendNoUnits": "Sin {unit}",
"trendNoDataBefore": "Sin datos antes de {date}",
"trendArriving": "Aún llegando datos",
"trendStillConverting": "Conversión en curso",
"trendStillConvertingToday": "Conversión en curso (hoy)",
"trendStillConvertingTooltip": "Algunos de estos {unit} aún pueden alcanzar el paso {step}, por lo que esta tasa puede aumentar.",
"trendLowVolume": "Volumen bajo",
"trendLowVolumeTooltip": "Han entrado menos de {n} {unit}, por lo que esta tasa varía mucho de un caso a otro.",
"trendPartial": "Parcial",
"trendPartialTooltip": "El rango cubre solo una parte de {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Entradas en el paso {n}",
"trendReached": "Alcanzaron el paso {n}",
"trendPrevSameDay": "Periodo anterior, mismo día",
"trendPrevSameWeek": "Periodo anterior, misma semana",
"trendPrevSameMonth": "Periodo anterior, mismo mes",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · sin {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "No hay {unit} en este rango",
"trendEmptyDescription": "Nadie alcanzó el paso {step} entre estas fechas. Prueba con un rango más amplio.",
"trendTooShort": "Elige un rango de al menos 2 días para ver la conversión a lo largo del tiempo",
"trendTooMany": "Elige un rango más corto o un intervalo más largo para ver la conversión a lo largo del tiempo",
"trendAverage": "Conversión media",
"trendAverageTooltip": "Calculada sobre cada {interval} cuya conversión ha finalizado; se excluyen los que aún están convirtiendo.",
"trendTooltipRate": "{rate} ({reached} de {entered})",
"trendTooltipPrev": "Periodo anterior, {date}: {rate}",
"alertTrend": "Cambia a Pasos para configurar una alerta",
"overallVs": "frente a {pct} en {range}",
"overallVsAgo": "frente a {pct} {label} ({range})",
"compareEmpty": "No hay {unit} en {range} para comparar",
"breakdownPrevTooltip": "{last} de {first} {unit} en {range}",
"nextStep": "Siguiente paso, más común después del paso {step}:",
"searchStep": "Buscar páginas y clics",
"searchStepAll": "Buscar páginas, clics, eventos y errores",
@@ -2261,6 +2333,9 @@
"atLeast": "al menos",
"notSet": "(no establecido)",
"other": "Otro",
"leftBefore": "Abandonaron antes del paso {step}",
"reachedNoValue": "Alcanzaron el paso {step}, sin valor",
"breakdownFieldMissing": "{field} no es un campo en los datos de esta aplicación, por lo que todos los {unit} se interpretan como no establecidos.",
"total": "Total",
"conversion": "Conversión",
"usersFallback": "Este enlace cuenta usuarios, pero no se resuelve ninguna identidad de usuario para esta aplicación y rango, por lo que cuenta sesiones.",
@@ -2273,17 +2348,20 @@
"pickFirst": "O elige el paso 1 de páginas y clics",
"overall": "Conversión general",
"overallOf": "{last} de {first} {unit}",
"overallTime": "· mediana {median} para convertir (p90 {p90})",
"countBy": "Contar por",
"sessions": "Sesiones",
"sessionNoun": "sesiones",
"windowSession": "Misma sesión",
"window1h": "Dentro de 1 hora",
"window1d": "Dentro de 1 día",
"window7d": "Dentro de 7 días",
"noBreakdown": "Sin desglose",
"breakdownLabel": "Desglose",
"breakdownNone": "Ninguno",
"windowSession": "la misma sesión · una visita",
"windowSessionShort": "la misma sesión",
"window1h": "1 hora",
"window1d": "1 día",
"window7d": "7 días",
"windowOfStep1": "{window} · del paso 1",
"whoFinishIn": "que completan los pasos en",
"viewAs": "Ver como",
"headlineWithin": "dentro de {window} desde el paso 1",
"headlineMedian": "mediana de {value} para convertir",
"headlineEstimated": "est.",
"clearSteps": "Borrar pasos",
"dims": {
"browser": "Navegador",
@@ -2303,7 +2381,6 @@
"usersExact": "El modo Usuarios se ejecuta de forma exacta y puede ser lento en este rango.",
"step1EmptyTitle": "{unit} no alcanzaron el paso 1 en este rango",
"step1EmptyDescription": "Nadie alcanzó {step} en este rango. Amplía el rango o empieza desde una página principal.",
"breakdownTimeHidden": "El tiempo de conversión se muestra para el embudo sin desglose.",
"alertMe": "Alertarme cuando la conversión caiga por debajo de la de hoy",
"alertNeedsRun": "Crea un embudo de dos o más pasos para alertar sobre él",
"alertWaiting": "Espera a que el embudo termine de contar",
@@ -2371,7 +2448,64 @@
"filterRejected": "Estos valores no se pueden usar como filtro. Cada valor tiene entre 1 y 1024 caracteres, como máximo 20.",
"whyEstimated": "Estimado: es posible que estas líneas no sumen exactamente el recuento de entradas.",
"tooLongForLink": "Este paso hace que el embudo sea demasiado largo para caber en su enlace; acorta o elimina un paso primero",
"editTooLongForLink": "Este cambio hace que el embudo sea demasiado largo para mantenerlo en su enlace; elimina primero un paso, un filtro o una exclusión"
"editTooLongForLink": "Este cambio hace que el embudo sea demasiado largo para mantenerlo en su enlace; elimina primero un paso, un filtro o una exclusión",
"breakdownRemove": "Quitar desglose",
"breakdownAdd": "Añadir desglose",
"breakdownCaption": "Divide cada paso por un campo, como navegador o método de pago",
"breakdownOneAtATime": "Un desglose a la vez",
"breakdownReplace": "Sustituir el desglose por {field}",
"breakdownValueFrom": "Valor de",
"pickerOnSteps": "En estos pasos",
"pickerKeyFields": "Campos clave",
"pickerUser": "Usuario",
"pickerAllFields": "Todos los campos",
"pickerStepHint": "Paso {step} · {values}",
"pickerStepHintMore": "Paso {step} · {values} +{more}",
"pickerManyValues": "Paso {step} · muchos valores",
"pickerUserReason": "Los campos de usuario solo se ofrecen para filtros",
"pickerSampling": "Leyendo los campos de los pasos de este embudo…",
"pickerSampleFailed": "No se pudieron leer los campos de los pasos de este embudo",
"fromStep": "Desde el paso {step}",
"fromAnyStep": "Desde cualquier paso · {pct} definido",
"fromAutomatic": "Automático (paso {step})",
"fromAutomaticNone": "Automático",
"fromStepItem": "Paso {step} · {name}",
"stepMenu": "Acciones del paso",
"breakDownByStep": "Desglosar por una propiedad de este paso",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Muchos valores",
"breakdownBy": "Por {field}",
"headlineBreakdown": "por {field}",
"breakdownAbout": "Acerca de este desglose",
"breakdownAboutAuto": "{field} se define por primera vez en el paso {step}, {name}, en {covered} de los {reached} {unit} que lo alcanzaron, por lo que sus tasas comienzan en el paso {step}.",
"breakdownAboutPinned": "{field} se lee en el paso {step}, {name}, el paso elegido para él; {covered} de los {reached} {unit} que lo alcanzaron tienen un valor, por lo que sus tasas comienzan en el paso {step}.",
"breakdownAboutAny": "{field} se define en {covered} de los {reached} {unit}, y en ningún paso para la mitad de quienes llegan a él, por lo que sus tasas comienzan en el paso 1.",
"breakdownAboutLeft": "Quienes abandonaron antes del paso {step} no tienen valor y se cuentan en una fila.",
"conversionFromStep": "Conversión desde el paso {step}",
"medianTime": "Tiempo mediano",
"lowVolumeTag": "Volumen bajo",
"lowVolumeNote": "Los valores por debajo de {count} {unit} en el paso {step} se marcan como volumen bajo",
"propertyTitle": "Propiedad",
"propertyField": "Campo",
"propertyAddValue": "Valor",
"propertyAdd": "Añadir propiedad",
"propertyRemove": "Quitar propiedad",
"propertyInvalid": "Patrón no válido",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "No se han visto campos en este paso",
"propertyFieldsLoading": "Leyendo los campos de este paso…",
"propertyFieldsFailed": "No se pudieron leer los campos de este paso",
"propConditions": {
"in": "es",
"notIn": "no es",
"contains": "contiene",
"regex": "coincide con"
},
"filterNotice": "Conserva {kept} de {opened} {unit}.",
"filterNoticeAction": "Desglosar por {field}",
"filterNoticeWhy": "Por qué cada paso muestra el 100 %",
"filterNoticeReason": "El filtro de todos los pasos conserva o descarta {unit} en su totalidad. {missing} de los {opened} {unit} que abrieron el paso 1 nunca definieron {field}, por lo que se descartan antes del paso 1 y los primeros pasos pueden mostrar el 100 %.",
"pickerOnStep": "En el paso {step}"
},
"viewer": {
"droppedAfter": "Abandonaron después del paso {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Embudo: {first} → {last}",
"funnelTrendTitle": "Conversión a lo largo del tiempo: {first} → {last}",
"step": "Paso",
"add": "Añadir al dashboard",
"funnelOnly": "Los dashboards admiten embudos de Sessions sin desglose",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Une session qui a fait cela entre les deux étapes cesse d'être comptée à la deuxième étape.",
"addExclusionStep": "Ajouter une étape d'exclusion",
"railSteps": "Étapes",
"railSettings": "Réglages",
"collapseRail": "Réduire le panneau latéral",
"expandRail": "Développer le panneau latéral",
"addStep": "Ajouter une étape",
"railWindow": "Fenêtre",
"railBreakdown": "Répartition",
"where": "où",
"alert": "Alerte",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Le panneau conserve ces étapes, ces filtres et ces règles d'événements nommés tels qu'ils sont maintenant, pour toute l'application, sans l'environnement, la version ni les applications comparées de cette page. Utilise les filtres d'application de cette app tels qu'actuellement ; recréez-le après leur modification.",
"needsNewer": "Ce tunnel nécessite une version plus récente",
"step": "Étape",
"ofFirst": "De l'étape 1",
"ofPrevious": "Depuis l'étape précédente",
"ofPreviousTooltip": "Part de l'étape précédente ayant atteint celle-ci",
"ofFirst": "Conversion depuis l'étape 1",
"ofPrevious": "Conversion depuis l'étape précédente",
"ofPreviousTooltip": "Part de l'étape précédente ayant atteint cette étape",
"ofFirstTooltip": "Part de l'étape 1 ayant atteint cette étape. Partie hachurée : supprimée par les étapes d'exclusion",
"ofFirstCompareTooltip": "Part de l'étape 1 ayant atteint cette étape. Barre pleine : cette période. Barre hachurée : période précédente.",
"unitTooltip": "{unit} ayant atteint cette étape",
"timeFromFirst": "Temps depuis l'étape 1",
"timeTooltip": "Temps le plus court dans l'ordre depuis un événement de l'étape 1 jusqu'à cette étape, par {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Voir pourquoi ils ont abandonné",
"droppedAria": "{count} abandon ({pct}) après l'étape {step}, voir pourquoi dans un panneau latéral",
"noneDropped": "0 abandon",
"droppedCompare": "{pct} d'abandons ({count}) vs {prevPct} ({prevCount}) sur la période précédente",
"droppedCompareAria": "{pct} d'abandons ({count}) vs {prevPct} ({prevCount}) sur la période précédente, après l'étape {step}, voir pourquoi dans un panneau latéral",
"droppedCompareTooltip": "Cette période, {range} : {pct} de {outOf} {unit}. Période précédente, {prevRange} : {prevPct} de {prevOutOf} {unit}. Ouvre le détail des abandons de cette période.",
"compareTooLong": "La comparaison fonctionne sur des plages allant jusqu'à 31 jours. Choisissez une plage plus courte pour comparer.",
"compareTo": "Comparer à",
"compareOff": "Désactivé",
"comparePrev": "Période précédente",
"compareAgo1h": "il y a 1 heure",
"compareAgo1d": "il y a 1 jour",
"compareAgo1w": "il y a 1 semaine",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "chevauche cette plage",
"compareSameAsPrev": "identique à la période précédente",
"compareNoLineAbout": "À propos de la ligne de comparaison manquante",
"compareNoLineBreakdown": "Le graphique n'affiche pas de ligne de comparaison lorsqu'une répartition est activée : chaque valeur de {field} constitue sa propre ligne. L'indicateur principal compare toujours l'ensemble de l'entonnoir avec {range}.",
"thisPeriod": "Cette période",
"changeHeader": "Variation (pts)",
"changeHeaderTooltip": "Variation en points de pourcentage pour la conversion depuis l'étape précédente.",
"changePts": "{arrow}{value} pts",
"lowVolume": "≈ faible volume",
"changeTooltip": "Intervalle à 95 % : ± {ci} pts. Sur {cur} {unit} pour cette période et {prev} pour la période précédente.",
"previousFailed": "La période précédente n'a pas pu être chargée",
"previousCount": "vs {count}",
"viewSteps": "Étapes",
"viewTrend": "Tendance",
"trendPairOption": "Étape {a} → Étape {b}",
"trendFrom": "Conversion de",
"trendTo": "à",
"trendLastStep": "{step} (dernière étape)",
"trendFromHere": "Afficher la tendance de conversion depuis cette étape",
"trendInterval": "Intervalle",
"intervalAuto": "Auto",
"intervalAutoResolved": "Auto ({interval})",
"intervalDay": "Jour",
"intervalWeek": "Semaine",
"intervalMonth": "Mois",
"intervalNameDay": "jour",
"intervalNameWeek": "semaine",
"intervalNameMonth": "mois",
"trendAxis": "Conversion (%)",
"trendNoUnits": "Pas de {unit}",
"trendNoDataBefore": "Aucune donnée avant le {date}",
"trendArriving": "Données encore en cours d'arrivée",
"trendStillConverting": "Conversion en cours",
"trendStillConvertingToday": "Conversion en cours (aujourd'hui)",
"trendStillConvertingTooltip": "Il se peut que des {unit} atteignent encore l'étape {step} ; ce taux peut donc augmenter.",
"trendLowVolume": "Volume faible",
"trendLowVolumeTooltip": "Avec moins de {n} {unit} au départ, ce taux varie fortement.",
"trendPartial": "Partiel",
"trendPartialTooltip": "La plage ne couvre qu'une partie de cet {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Entrés à l'étape {n}",
"trendReached": "Étape {n} atteinte",
"trendPrevSameDay": "Période précédente, même jour",
"trendPrevSameWeek": "Période précédente, même semaine",
"trendPrevSameMonth": "Période précédente, même mois",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · pas de {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Pas de {unit} dans cette plage",
"trendEmptyDescription": "Personne n'a atteint l'étape {step} entre ces dates. Essayez une plage plus large.",
"trendTooShort": "Choisissez une plage d'au moins 2 jours pour voir la conversion dans le temps",
"trendTooMany": "Choisissez une plage plus courte ou un intervalle plus long pour voir la conversion dans le temps",
"trendAverage": "Conversion moyenne",
"trendAverageTooltip": "Somme sur chaque {interval} dont la conversion est clôturée ; les périodes encore en conversion sont exclues.",
"trendTooltipRate": "{rate} ({reached} sur {entered})",
"trendTooltipPrev": "Période précédente, {date} : {rate}",
"alertTrend": "Passez à Étapes pour définir une alerte",
"overallVs": "vs {pct} dans {range}",
"overallVsAgo": "vs {pct} {label} ({range})",
"compareEmpty": "Pas de {unit} dans {range} pour comparer",
"breakdownPrevTooltip": "{last} sur {first} {unit} dans {range}",
"nextStep": "Étape suivante, la plus courante après l'étape {step} :",
"searchStep": "Rechercher des pages et des clics",
"searchStepAll": "Rechercher des pages, des clics, des événements et des erreurs",
@@ -2261,6 +2333,9 @@
"atLeast": "au moins",
"notSet": "(non défini)",
"other": "Autre",
"leftBefore": "Partis avant l'étape {step}",
"reachedNoValue": "Étape {step} atteinte, aucune valeur",
"breakdownFieldMissing": "{field} n'est pas un champ des données de cette application ; les {unit} apparaissent donc sans valeur définie.",
"total": "Total",
"conversion": "Conversion",
"usersFallback": "Ce lien compte les utilisateurs, mais aucune identité utilisateur n'est résolue pour cette application et cette période, il compte donc les sessions.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Ou choisissez l'étape 1 parmi les pages et les clics",
"overall": "Conversion globale",
"overallOf": "{last} sur {first} {unit}",
"overallTime": "· médiane {median} pour convertir (p90 {p90})",
"countBy": "Compter par",
"sessions": "Sessions",
"sessionNoun": "sessions",
"windowSession": "Même session",
"window1h": "En 1 heure",
"window1d": "En 1 jour",
"window7d": "En 7 jours",
"noBreakdown": "Aucune ventilation",
"breakdownLabel": "Répartition",
"breakdownNone": "Aucune",
"windowSession": "la même session · une visite",
"windowSessionShort": "la même session",
"window1h": "1 heure",
"window1d": "1 jour",
"window7d": "7 jours",
"windowOfStep1": "{window} · depuis l'étape 1",
"whoFinishIn": "qui terminent les étapes en",
"viewAs": "Afficher en",
"headlineWithin": "dans les {window} suivant l'étape 1",
"headlineMedian": "médiane de {value} pour convertir",
"headlineEstimated": "est.",
"clearSteps": "Effacer les étapes",
"dims": {
"browser": "Navigateur",
@@ -2303,7 +2381,6 @@
"usersExact": "Le mode Utilisateurs s'exécute en exact et peut être lent sur cette plage.",
"step1EmptyTitle": "Aucun(e) {unit} n'a atteint l'étape 1 dans cette plage",
"step1EmptyDescription": "Personne n'a atteint {step} dans cette plage. Élargissez la plage, ou partez plutôt d'une page du top.",
"breakdownTimeHidden": "Le temps de conversion est affiché pour le tunnel sans répartition.",
"alertMe": "M'alerter lorsque la conversion passe sous celle d'aujourd'hui",
"alertNeedsRun": "Créez un tunnel d'au moins deux étapes pour y associer une alerte",
"alertWaiting": "Attendez que le tunnel ait terminé son comptage",
@@ -2371,7 +2448,64 @@
"filterRejected": "Ces valeurs ne peuvent pas être utilisées comme filtre. Chaque valeur comporte de 1 à 1024 caractères, 20 au maximum.",
"whyEstimated": "Estimation : ces lignes peuvent ne pas s'additionner exactement au nombre d'entrées.",
"tooLongForLink": "Cette étape rend le tunnel trop long pour être conservé dans son lien ; raccourcissez ou supprimez d'abord une étape",
"editTooLongForLink": "Cette modification rend le tunnel trop long pour être conservé dans son lien ; supprimez d'abord une étape, un filtre ou une exclusion"
"editTooLongForLink": "Cette modification rend le tunnel trop long pour être conservé dans son lien ; supprimez d'abord une étape, un filtre ou une exclusion",
"breakdownRemove": "Supprimer la répartition",
"breakdownAdd": "Ajouter une répartition",
"breakdownCaption": "Répartir chaque étape selon un champ, comme le navigateur ou le moyen de paiement",
"breakdownOneAtATime": "Une seule répartition à la fois",
"breakdownReplace": "Remplacer la répartition par {field}",
"breakdownValueFrom": "Valeur de",
"pickerOnSteps": "Sur ces étapes",
"pickerKeyFields": "Champs clés",
"pickerUser": "Utilisateur",
"pickerAllFields": "Tous les champs",
"pickerStepHint": "Étape {step} · {values}",
"pickerStepHintMore": "Étape {step} · {values} +{more}",
"pickerManyValues": "Étape {step} · de nombreuses valeurs",
"pickerUserReason": "Les champs utilisateur sont proposés uniquement pour les filtres",
"pickerSampling": "Lecture des champs des étapes de cet entonnoir…",
"pickerSampleFailed": "Impossible de lire les champs des étapes de cet entonnoir",
"fromStep": "À partir de l'étape {step}",
"fromAnyStep": "Depuis n'importe quelle étape · {pct} renseignés",
"fromAutomatic": "Automatique (étape {step})",
"fromAutomaticNone": "Automatique",
"fromStepItem": "Étape {step} · {name}",
"stepMenu": "Actions de l'étape",
"breakDownByStep": "Ventiler par une propriété de cette étape",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Nombreuses valeurs",
"breakdownBy": "Par {field}",
"headlineBreakdown": "par {field}",
"breakdownAbout": "À propos de cette ventilation",
"breakdownAboutAuto": "{field} est défini pour la première fois à l'étape {step}, {name}, pour {covered} des {reached} {unit} qui l'ont atteinte, ses taux commencent donc à l'étape {step}.",
"breakdownAboutPinned": "{field} est lu à l'étape {step}, {name}, l'étape choisie pour lui ; {covered} des {reached} {unit} qui l'ont atteinte portent une valeur, ses taux commencent donc à l'étape {step}.",
"breakdownAboutAny": "{field} est défini pour {covered} des {reached} {unit}, et sur aucune étape pour la moitié de celles qui l'atteignent, ses taux commencent donc à l'étape 1.",
"breakdownAboutLeft": "Celles qui ont quitté avant l'étape {step} n'ont pas de valeur et sont comptées dans une seule ligne.",
"conversionFromStep": "Conversion depuis l'étape {step}",
"medianTime": "Temps médian",
"lowVolumeTag": "Faible volume",
"lowVolumeNote": "Les valeurs inférieures à {count} {unit} à l'étape {step} sont marquées comme faible volume",
"propertyTitle": "Propriété",
"propertyField": "Champ",
"propertyAddValue": "Valeur",
"propertyAdd": "Ajouter une propriété",
"propertyRemove": "Supprimer la propriété",
"propertyInvalid": "Motif non valide",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Aucun champ observé à cette étape",
"propertyFieldsLoading": "Lecture des champs de cette étape…",
"propertyFieldsFailed": "Impossible de lire les champs de cette étape",
"propConditions": {
"in": "est",
"notIn": "n'est pas",
"contains": "contient",
"regex": "correspond à"
},
"filterNotice": "Conserve {kept} sur {opened} {unit}.",
"filterNoticeAction": "Ventiler par {field}",
"filterNoticeWhy": "Pourquoi chaque étape affiche 100 %",
"filterNoticeReason": "Le filtre sur toutes les étapes conserve ou supprime des {unit} entiers. {missing} des {opened} {unit} ayant ouvert l'étape 1 n'ont jamais défini {field}, ils sont donc supprimés avant l'étape 1 et les premières étapes peuvent afficher 100 %.",
"pickerOnStep": "À l'étape {step}"
},
"viewer": {
"droppedAfter": "Abandon après l'étape {step} : {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Entonnoir : {first} → {last}",
"funnelTrendTitle": "Conversion dans le temps : {first} → {last}",
"step": "Étape",
"add": "Ajouter au tableau de bord",
"funnelOnly": "Les tableaux de bord prennent en charge les entonnoirs Sessions sans répartition",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Una sessione che ha eseguito questa azione tra i due passi smette di essere conteggiata al secondo passo.",
"addExclusionStep": "Aggiungi passo di esclusione",
"railSteps": "Passi",
"railSettings": "Impostazioni",
"collapseRail": "Comprimi pannello laterale",
"expandRail": "Espandi pannello laterale",
"addStep": "Aggiungi passo",
"railWindow": "Finestra",
"railBreakdown": "Ripartizione",
"where": "dove",
"alert": "Avviso",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Il pannello mantiene questi passaggi, filtri e regole degli eventi denominati così come sono ora, per l'intera app, senza l'env, la versione o le app confrontate di questa pagina. Usa i filtri app di questa app al momento attuale; ricrealo dopo che cambiano.",
"needsNewer": "Questo funnel richiede una versione più recente",
"step": "Passaggio",
"ofFirst": "Del passaggio 1",
"ofPrevious": "Dal precedente",
"ofPreviousTooltip": "Quota del passaggio precedente che ha raggiunto questo",
"ofFirst": "Conversione dal passaggio 1",
"ofPrevious": "Conversione dal passaggio precedente",
"ofPreviousTooltip": "Quota del passaggio precedente che ha raggiunto questo passaggio",
"ofFirstTooltip": "Quota del passaggio 1 che ha raggiunto questo passaggio. Parte tratteggiata: rimossa dai passaggi di esclusione",
"ofFirstCompareTooltip": "Quota del passaggio 1 che ha raggiunto questo passaggio. Barra piena: questo periodo. Barra tratteggiata: periodo precedente.",
"unitTooltip": "{unit} che hanno raggiunto questo passaggio",
"timeFromFirst": "Tempo dal passaggio 1",
"timeTooltip": "Tempo minimo in ordine da un evento del passaggio 1 a questo passaggio, per {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Scopri perché hanno abbandonato",
"droppedAria": "{count} abbandonati ({pct}) dopo il passaggio {step}, scopri perché in un pannello laterale",
"noneDropped": "0 abbandonati",
"droppedCompare": "{pct} persi ({count}) rispetto a {prevPct} ({prevCount}) nel periodo precedente",
"droppedCompareAria": "{pct} persi ({count}) rispetto a {prevPct} ({prevCount}) nel periodo precedente, dopo il passaggio {step}, scopri il motivo in un pannello laterale",
"droppedCompareTooltip": "Questo periodo, {range}: {pct} di {outOf} {unit}. Periodo precedente, {prevRange}: {prevPct} di {prevOutOf} {unit}. Apre il dettaglio degli abbandoni di questo periodo.",
"compareTooLong": "Il confronto è disponibile per intervalli fino a 31 giorni. Scegli un intervallo più breve da confrontare.",
"compareTo": "Confronta con",
"compareOff": "Disattivato",
"comparePrev": "Periodo precedente",
"compareAgo1h": "1 ora fa",
"compareAgo1d": "1 giorno fa",
"compareAgo1w": "1 settimana fa",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "si sovrappone a questo intervallo",
"compareSameAsPrev": "uguale al periodo precedente",
"compareNoLineAbout": "Informazioni sulla linea di confronto mancante",
"compareNoLineBreakdown": "Il grafico non ha una linea di confronto quando è attiva una suddivisione: ogni valore di {field} ha una propria linea. Il valore principale confronta comunque l'intero funnel con {range}.",
"thisPeriod": "Questo periodo",
"changeHeader": "Variazione (punti)",
"changeHeaderTooltip": "Variazione in punti percentuali, nella conversione dal passaggio precedente.",
"changePts": "{arrow}{value} punti",
"lowVolume": "≈ volume basso",
"changeTooltip": "Intervallo al 95%: ± {ci} punti. Su {cur} {unit} in questo periodo e {prev} nel periodo precedente.",
"previousFailed": "Impossibile caricare il periodo precedente",
"previousCount": "vs {count}",
"viewSteps": "Passaggi",
"viewTrend": "Andamento",
"trendPairOption": "Passaggio {a} → Passaggio {b}",
"trendFrom": "Conversione da",
"trendTo": "a",
"trendLastStep": "{step} (ultimo passaggio)",
"trendFromHere": "Mostra l'andamento della conversione da questo passaggio",
"trendInterval": "Intervallo",
"intervalAuto": "Auto",
"intervalAutoResolved": "Auto ({interval})",
"intervalDay": "Giorno",
"intervalWeek": "Settimana",
"intervalMonth": "Mese",
"intervalNameDay": "giorno",
"intervalNameWeek": "settimana",
"intervalNameMonth": "mese",
"trendAxis": "Conversione (%)",
"trendNoUnits": "Nessun {unit}",
"trendNoDataBefore": "Nessun dato prima di {date}",
"trendArriving": "Dati ancora in arrivo",
"trendStillConverting": "Conversione ancora in corso",
"trendStillConvertingToday": "Conversione ancora in corso (oggi)",
"trendStillConvertingTooltip": "Una parte di {unit} può ancora raggiungere il passo {step}, quindi questo tasso potrebbe aumentare.",
"trendLowVolume": "Volume basso",
"trendLowVolumeTooltip": "Con meno di {n} {unit} in ingresso, questo tasso varia molto da un'unità all'altra.",
"trendPartial": "Parziale",
"trendPartialTooltip": "L'intervallo copre solo una parte di {interval} in corso.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Ingresso al passo {n}",
"trendReached": "Passo {n} raggiunto",
"trendPrevSameDay": "Periodo precedente, stesso giorno",
"trendPrevSameWeek": "Periodo precedente, stessa settimana",
"trendPrevSameMonth": "Periodo precedente, stesso mese",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · {unit} assenti",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "{unit} assenti in questo intervallo",
"trendEmptyDescription": "Nessuno ha raggiunto il passo {step} tra queste date. Prova un intervallo più ampio.",
"trendTooShort": "Scegli un intervallo di almeno 2 giorni per vedere la conversione nel tempo",
"trendTooMany": "Scegli un intervallo di date più breve o una granularità maggiore per vedere la conversione nel tempo",
"trendAverage": "Conversione media",
"trendAverageTooltip": "Somma su ogni {interval} la cui conversione è conclusa; quelli ancora in conversione sono esclusi.",
"trendTooltipRate": "{rate} ({reached} di {entered})",
"trendTooltipPrev": "Periodo precedente, {date}: {rate}",
"alertTrend": "Passa a Passi per impostare un avviso",
"overallVs": "rispetto a {pct} in {range}",
"overallVsAgo": "rispetto a {pct} {label} ({range})",
"compareEmpty": "Nessun dato per {unit} in {range} con cui confrontare",
"breakdownPrevTooltip": "{last} di {first} {unit} in {range}",
"nextStep": "Passaggio successivo, più comune dopo il passaggio {step}:",
"searchStep": "Cerca pagine e clic",
"searchStepAll": "Cerca pagine, clic, eventi ed errori",
@@ -2261,6 +2333,9 @@
"atLeast": "almeno",
"notSet": "(non impostato)",
"other": "Altro",
"leftBefore": "Uscita prima del passo {step}",
"reachedNoValue": "Passo {step} raggiunto, nessun valore",
"breakdownFieldMissing": "{field} non è un campo nei dati di questa app, quindi per {unit} il valore risulta non impostato.",
"total": "Totale",
"conversion": "Conversione",
"usersFallback": "Questo link conta gli utenti, ma nessuna identità utente viene risolta per questa app e questo intervallo, quindi conta le sessioni.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Oppure scegli il passaggio 1 da pagine e clic",
"overall": "Conversione complessiva",
"overallOf": "{last} di {first} {unit}",
"overallTime": "· mediana {median} per convertire (p90 {p90})",
"countBy": "Conteggio per",
"sessions": "Sessioni",
"sessionNoun": "sessioni",
"windowSession": "Stessa sessione",
"window1h": "Entro 1 ora",
"window1d": "Entro 1 giorno",
"window7d": "Entro 7 giorni",
"noBreakdown": "Nessuna suddivisione",
"breakdownLabel": "Ripartizione",
"breakdownNone": "Nessuna",
"windowSession": "la stessa sessione · una visita",
"windowSessionShort": "la stessa sessione",
"window1h": "1 ora",
"window1d": "1 giorno",
"window7d": "7 giorni",
"windowOfStep1": "{window} · dal passo 1",
"whoFinishIn": "che completano i passi in",
"viewAs": "Visualizza come",
"headlineWithin": "entro {window} dal passo 1",
"headlineMedian": "mediana di {value} per convertire",
"headlineEstimated": "stim.",
"clearSteps": "Cancella passaggi",
"dims": {
"browser": "Browser",
@@ -2303,7 +2381,6 @@
"usersExact": "La modalità Utenti viene eseguita in modo esatto e può essere lenta su questo intervallo.",
"step1EmptyTitle": "Nessun {unit} ha raggiunto il passo 1 in questo intervallo",
"step1EmptyDescription": "Nessuno ha raggiunto {step} in questo intervallo. Amplia l'intervallo oppure inizia da una pagina principale.",
"breakdownTimeHidden": "Il tempo di conversione è mostrato per il funnel senza ripartizione.",
"alertMe": "Avvisami quando la conversione scende sotto quella odierna",
"alertNeedsRun": "Crea un funnel con due o più passi per impostare un avviso",
"alertWaiting": "Attendi che il funnel termini il conteggio",
@@ -2371,7 +2448,64 @@
"filterRejected": "Questi valori non possono essere usati come filtro. Ogni valore ha da 1 a 1024 caratteri, al massimo 20.",
"whyEstimated": "Stimato: queste righe potrebbero non sommare esattamente al conteggio delle entrate.",
"tooLongForLink": "Questo passo rende il funnel troppo lungo per essere mantenuto nel suo link; accorcia o rimuovi prima un passo",
"editTooLongForLink": "Questa modifica rende il funnel troppo lungo per essere mantenuto nel suo link; rimuovi prima un passo, un filtro o un'esclusione"
"editTooLongForLink": "Questa modifica rende il funnel troppo lungo per essere mantenuto nel suo link; rimuovi prima un passo, un filtro o un'esclusione",
"breakdownRemove": "Rimuovi ripartizione",
"breakdownAdd": "Aggiungi ripartizione",
"breakdownCaption": "Suddividi ogni passo per un campo, ad esempio browser o metodo di pagamento",
"breakdownOneAtATime": "Una ripartizione alla volta",
"breakdownReplace": "Sostituisci la ripartizione con {field}",
"breakdownValueFrom": "Valore da",
"pickerOnSteps": "Su questi passi",
"pickerKeyFields": "Campi chiave",
"pickerUser": "Utente",
"pickerAllFields": "Tutti i campi",
"pickerStepHint": "Passaggio {step} · {values}",
"pickerStepHintMore": "Passaggio {step} · {values} +{more}",
"pickerManyValues": "Passaggio {step} · molti valori",
"pickerUserReason": "I campi utente sono proposti solo per i filtri",
"pickerSampling": "Lettura dei campi nei passaggi di questo funnel…",
"pickerSampleFailed": "Impossibile leggere i campi nei passaggi di questo funnel",
"fromStep": "Dal passaggio {step}",
"fromAnyStep": "Da qualsiasi passaggio · {pct} impostato",
"fromAutomatic": "Automatico (passaggio {step})",
"fromAutomaticNone": "Automatico",
"fromStepItem": "Passaggio {step} · {name}",
"stepMenu": "Azioni del passaggio",
"breakDownByStep": "Scomponi per una proprietà di questo passaggio",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Molti valori",
"breakdownBy": "Per {field}",
"headlineBreakdown": "per {field}",
"breakdownAbout": "Informazioni su questa suddivisione",
"breakdownAboutAuto": "Il campo {field} viene impostato per la prima volta al passaggio {step}, {name}, in {covered} dei {reached} {unit} che lo hanno raggiunto, quindi i relativi tassi partono dal passaggio {step}.",
"breakdownAboutPinned": "Il campo {field} viene letto al passaggio {step}, {name}, il passaggio scelto per esso; {covered} dei {reached} {unit} che lo hanno raggiunto hanno un valore, quindi i relativi tassi partono dal passaggio {step}.",
"breakdownAboutAny": "Il campo {field} è impostato su {covered} dei {reached} {unit}, e in nessun passaggio per metà di quelli che lo raggiungono, quindi i relativi tassi partono dal passaggio 1.",
"breakdownAboutLeft": "Quelli che hanno abbandonato prima del passaggio {step} non hanno valore e sono conteggiati in un'unica riga.",
"conversionFromStep": "Conversione dal passaggio {step}",
"medianTime": "Tempo mediano",
"lowVolumeTag": "Volume ridotto",
"lowVolumeNote": "I valori inferiori a {count} {unit} nel passaggio {step} sono contrassegnati come volume ridotto",
"propertyTitle": "Proprietà",
"propertyField": "Campo",
"propertyAddValue": "Valore",
"propertyAdd": "Aggiungi proprietà",
"propertyRemove": "Rimuovi proprietà",
"propertyInvalid": "Non è un pattern valido",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Nessun campo rilevato in questo passaggio",
"propertyFieldsLoading": "Lettura dei campi di questo passaggio…",
"propertyFieldsFailed": "Impossibile leggere i campi di questo passaggio",
"propConditions": {
"in": "è",
"notIn": "non è",
"contains": "contiene",
"regex": "corrisponde a"
},
"filterNotice": "Mantiene {kept} di {opened} {unit}.",
"filterNoticeAction": "Scomponi per {field}",
"filterNoticeWhy": "Perché ogni passaggio mostra il 100%",
"filterNoticeReason": "Il filtro su tutti i passaggi mantiene o scarta {unit} in blocco. {missing} di {opened} {unit} che hanno aperto il passaggio 1 non hanno mai impostato {field}, quindi non arrivano al passaggio 1 e i primi passaggi possono mostrare il 100%.",
"pickerOnStep": "Nel passaggio {step}"
},
"viewer": {
"droppedAfter": "Abbandonato dopo il passo {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Funnel: {first} → {last}",
"funnelTrendTitle": "Conversione nel tempo: {first} → {last}",
"step": "Passaggio",
"add": "Aggiungi alla dashboard",
"funnelOnly": "Le dashboard supportano i funnel delle sessioni senza suddivisione",
+149 -14
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "2 つのステップの間にこの操作を行ったセッションは、2 番目のステップでカウントされなくなります。",
"addExclusionStep": "除外ステップを追加",
"railSteps": "ステップ",
"railSettings": "設定",
"collapseRail": "サイドパネルを折りたたむ",
"expandRail": "サイドパネルを展開",
"addStep": "ステップを追加",
"railWindow": "ウィンドウ",
"railBreakdown": "内訳",
"where": "条件",
"alert": "アラート",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "このパネルは、これらのステップ、フィルター、名前付きイベントルールを現状のまま、このページの環境・バージョン・比較対象アプリを除いたアプリ全体に対して保持します。現時点のこのアプリのアプリフィルターを使用します。変更後は再作成してください。",
"needsNewer": "このファネルには新しいバージョンが必要です",
"step": "ステップ",
"ofFirst": "ステップ1の",
"ofPrevious": "前のステップから",
"ofFirst": "ステップ1からのコンバージョン",
"ofPrevious": "前のステップからのコンバージョン",
"ofPreviousTooltip": "前のステップのうち、このステップに到達した割合",
"ofFirstTooltip": "ステップ1のうち、このステップに到達した割合。斜線部分: 除外ステップによって削除された分",
"ofFirstCompareTooltip": "ステップ1のうちこのステップに到達した割合。塗りつぶしバー: 今期間。斜線バー: 前期間。",
"unitTooltip": "このステップに到達した{unit}",
"timeFromFirst": "ステップ1からの時間",
"timeTooltip": "ステップ1のイベントからこのステップまでの最短順序時間({unit}あたり)",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "離脱理由を確認",
"droppedAria": "ステップ {step} の後に {count} 件離脱({pct})、理由はサイドパネルで確認",
"noneDropped": "離脱 0 件",
"droppedCompare": "{pct} 離脱({count})vs 前期間 {prevPct}({prevCount})",
"droppedCompareAria": "{pct} 離脱({count})vs 前期間 {prevPct}({prevCount})、ステップ {step} の後。理由はサイドパネルで確認できます",
"droppedCompareTooltip": "今期間、{range}: {pct}({outOf} {unit} 中)。前期間、{prevRange}: {prevPct}({prevOutOf} {unit} 中)。今期間の離脱を開きます。",
"compareTooLong": "比較は最大31日間の範囲で機能します。比較するには短い範囲を選択してください。",
"compareTo": "比較対象",
"compareOff": "オフ",
"comparePrev": "前期間",
"compareAgo1h": "1時間前",
"compareAgo1d": "1日前",
"compareAgo1w": "1週間前",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "この範囲と重複します",
"compareSameAsPrev": "前期間と同じ",
"compareNoLineAbout": "比較線が表示されない理由",
"compareNoLineBreakdown": "内訳がオンの間、チャートには比較線が表示されません。{field} の各値がそれぞれ別の線になります。見出しは引き続きファネル全体を {range} と比較します。",
"thisPeriod": "今期間",
"changeHeader": "変化(pts)",
"changeHeaderTooltip": "前のステップからのコンバージョンにおけるパーセントポイントの変化。",
"changePts": "{arrow}{value} pts",
"lowVolume": "≈ 低ボリューム",
"changeTooltip": "95% 区間: ± {ci} pts。今期間の {cur} {unit} 中、前期間の {prev} 中。",
"previousFailed": "前期間を読み込めませんでした",
"previousCount": "vs {count}",
"viewSteps": "ステップ",
"viewTrend": "トレンド",
"trendPairOption": "ステップ {a} → ステップ {b}",
"trendFrom": "コンバージョン元",
"trendTo": "コンバージョン先",
"trendLastStep": "{step}(最後のステップ)",
"trendFromHere": "このステップからのコンバージョントレンドを表示",
"trendInterval": "間隔",
"intervalAuto": "自動",
"intervalAutoResolved": "自動({interval})",
"intervalDay": "日",
"intervalWeek": "週",
"intervalMonth": "月",
"intervalNameDay": "日",
"intervalNameWeek": "週",
"intervalNameMonth": "月",
"trendAxis": "コンバージョン(%)",
"trendNoUnits": "{unit} なし",
"trendNoDataBefore": "{date} より前のデータはありません",
"trendArriving": "データは到着中",
"trendStillConverting": "コンバージョン中",
"trendStillConvertingToday": "コンバージョン中(今日)",
"trendStillConvertingTooltip": "これらの{unit}の一部はまだステップ{step}に到達する可能性があるため、この率は上がる可能性があります。",
"trendLowVolume": "データ量が少ない",
"trendLowVolumeTooltip": "{n} {unit}未満しか開始していないため、1件ごとにこの率は大きく変動します。",
"trendPartial": "一部",
"trendPartialTooltip": "この範囲は{interval}の一部のみをカバーしています。",
"trendCaption": "{range} · {tz}",
"trendEntered": "ステップ{n}に流入",
"trendReached": "ステップ{n}に到達",
"trendPrevSameDay": "前期間、同日",
"trendPrevSameWeek": "前期間、同週",
"trendPrevSameMonth": "前期間、同月",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · {unit}なし",
"trendPrevSeriesOf": "{range}({label})",
"trendEmpty": "この範囲に{unit}はありません",
"trendEmptyDescription": "この期間内にステップ{step}に到達した人はいません。範囲を広げてみてください。",
"trendTooShort": "コンバージョンの推移を表示するには、2日以上の範囲を選択してください",
"trendTooMany": "コンバージョンの推移を表示するには、より短い範囲またはより長い間隔を選択してください",
"trendAverage": "平均コンバージョン率",
"trendAverageTooltip": "コンバージョンが確定したすべての{interval}を合算したものです。コンバージョン継続中のものは除外されます。",
"trendTooltipRate": "{rate}({entered}中{reached})",
"trendTooltipPrev": "前期間、{date}: {rate}",
"alertTrend": "アラートを設定するにはステップ表示に切り替えてください",
"overallVs": "{range}の{pct}と比較",
"overallVsAgo": "{label}の{pct}と比較({range})",
"compareEmpty": "比較対象となる{unit}が{range}にありません",
"breakdownPrevTooltip": "{range}の{first} {unit}のうち{last}",
"nextStep": "次のステップ、ステップ {step} の後で最も多いもの:",
"searchStep": "ページとクリックを検索",
"searchStepAll": "ページ、クリック、イベント、エラーを検索",
@@ -2261,6 +2333,9 @@
"atLeast": "以上",
"notSet": "(未設定)",
"other": "その他",
"leftBefore": "ステップ{step}の前に離脱",
"reachedNoValue": "ステップ{step}に到達、値なし",
"breakdownFieldMissing": "{field}はこのアプリのデータ内のフィールドではないため、すべての{unit}が未設定として扱われます。",
"total": "合計",
"conversion": "コンバージョン",
"usersFallback": "このリンクはユーザーをカウントしますが、このアプリと範囲ではユーザー ID を特定できないため、セッションをカウントします。",
@@ -2273,17 +2348,20 @@
"pickFirst": "またはページとクリックからステップ1を選択",
"overall": "全体のコンバージョン",
"overallOf": "{first} {unit} のうち {last}",
"overallTime": "· コンバージョンまでの中央値 {median}(p90 {p90})",
"countBy": "カウント単位",
"sessions": "セッション",
"sessionNoun": "セッション",
"windowSession": "同じセッション",
"window1h": "1時間以内",
"window1d": "1日以内",
"window7d": "7日以内",
"noBreakdown": "内訳なし",
"breakdownLabel": "内訳",
"breakdownNone": "なし",
"windowSession": "同一セッション · 1回の訪問",
"windowSessionShort": "同一セッション",
"window1h": "1時間",
"window1d": "1日",
"window7d": "7日間",
"windowOfStep1": "{window} · ステップ1から",
"whoFinishIn": "ステップを完了するまでの",
"viewAs": "表示形式",
"headlineWithin": "ステップ1から{window}以内",
"headlineMedian": "コンバージョンまでの中央値 {value}",
"headlineEstimated": "推定",
"clearSteps": "ステップをクリア",
"dims": {
"browser": "ブラウザ",
@@ -2303,7 +2381,6 @@
"usersExact": "ユーザーモードは正確に実行されるため、この範囲では遅くなる可能性があります。",
"step1EmptyTitle": "この範囲ではステップ1に到達した {unit} はありません",
"step1EmptyDescription": "この範囲では誰も {step} に到達しませんでした。範囲を広げるか、代わりに上位ページから開始してください。",
"breakdownTimeHidden": "コンバージョンまでの時間は、内訳のないファネルの場合に表示されます。",
"alertMe": "コンバージョンが本日の値を下回ったときにアラートを受け取る",
"alertNeedsRun": "アラートを設定するには、2つ以上のステップからなるファネルを作成してください",
"alertWaiting": "ファネルの集計が完了するまで待ってください",
@@ -2371,7 +2448,64 @@
"filterRejected": "これらの値はフィルターとして使用できません。各値は1〜1024文字、最大20個です。",
"whyEstimated": "推定値: これらの行の合計は流入件数と完全には一致しない場合があります。",
"tooLongForLink": "このステップによりファネルが長すぎてリンクに保持できません。先にステップを短縮または削除してください",
"editTooLongForLink": "この変更によりファネルが長すぎてリンクに保持できなくなります。先にステップ、フィルター、除外のいずれかを削除してください"
"editTooLongForLink": "この変更によりファネルが長すぎてリンクに保持できなくなります。先にステップ、フィルター、除外のいずれかを削除してください",
"breakdownRemove": "内訳を削除",
"breakdownAdd": "内訳を追加",
"breakdownCaption": "各ステップをブラウザや支払い方法などの1つのフィールドで分割します",
"breakdownOneAtATime": "内訳は一度に1つまで",
"breakdownReplace": "内訳を{field}に置き換え",
"breakdownValueFrom": "値の取得元",
"pickerOnSteps": "これらのステップで",
"pickerKeyFields": "主要フィールド",
"pickerUser": "ユーザー",
"pickerAllFields": "すべてのフィールド",
"pickerStepHint": "ステップ {step} · {values}",
"pickerStepHintMore": "ステップ {step} · {values} +{more}",
"pickerManyValues": "ステップ {step} · 多数の値",
"pickerUserReason": "ユーザーフィールドはフィルター専用です",
"pickerSampling": "このファネルのステップのフィールドを読み取り中…",
"pickerSampleFailed": "このファネルのステップのフィールドを読み取れませんでした",
"fromStep": "ステップ {step} から",
"fromAnyStep": "任意のステップから · {pct} が設定済み",
"fromAutomatic": "自動 (ステップ {step})",
"fromAutomaticNone": "自動",
"fromStepItem": "ステップ {step} · {name}",
"stepMenu": "ステップの操作",
"breakDownByStep": "このステップのプロパティで内訳",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "多数の値",
"breakdownBy": "{field} 別",
"headlineBreakdown": "{field} 別",
"breakdownAbout": "この内訳について",
"breakdownAboutAuto": "{field} はステップ {step}({name})で最初に設定されます。そこに到達した {reached} {unit} のうち {covered} で設定されているため、その率はステップ {step} から始まります。",
"breakdownAboutPinned": "{field} は、そのために選択されたステップ {step}({name})で読み取られます。そこに到達した {reached} {unit} のうち {covered} が値を持つため、その率はステップ {step} から始まります。",
"breakdownAboutAny": "{field} は {reached} {unit} のうち {covered} で設定されており、そこに到達したものの半数ではどのステップでも設定されていないため、その率はステップ 1 から始まります。",
"breakdownAboutLeft": "ステップ {step} より前に離脱したものは値を持たず、1 つの行にまとめてカウントされます。",
"conversionFromStep": "ステップ {step} からのコンバージョン",
"medianTime": "所要時間の中央値",
"lowVolumeTag": "低ボリューム",
"lowVolumeNote": "ステップ {step} で {count} {unit} 未満の値は低ボリュームとしてマークされます",
"propertyTitle": "プロパティ",
"propertyField": "フィールド",
"propertyAddValue": "値",
"propertyAdd": "プロパティを追加",
"propertyRemove": "プロパティを削除",
"propertyInvalid": "有効なパターンではありません",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "このステップで確認できるフィールドはありません",
"propertyFieldsLoading": "このステップのフィールドを読み取り中…",
"propertyFieldsFailed": "このステップのフィールドを読み取れませんでした",
"propConditions": {
"in": "等しい",
"notIn": "等しくない",
"contains": "含む",
"regex": "一致する"
},
"filterNotice": "{opened} {unit} のうち {kept} を保持します。",
"filterNoticeAction": "{field} で内訳",
"filterNoticeWhy": "すべてのステップが 100% と表示される理由",
"filterNoticeReason": "すべてのステップをフィルターすると、{unit} 全体を保持または除外します。ステップ 1 を開始した {opened} {unit} のうち {missing} は {field} を一度も設定していないため、ステップ 1 より前に除外され、初期のステップが 100% と表示される可能性があります。",
"pickerOnStep": "ステップ {step} で"
},
"viewer": {
"droppedAfter": "ステップ {step} の後に離脱: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "ファネル: {first} → {last}",
"funnelTrendTitle": "コンバージョンの推移: {first} → {last}",
"step": "ステップ",
"add": "ダッシュボードに追加",
"funnelOnly": "ダッシュボードは内訳なしのセッションファネルに対応しています",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "두 단계 사이에 이 작업을 수행한 세션은 두 번째 단계에서 집계가 중단됩니다.",
"addExclusionStep": "제외 단계 추가",
"railSteps": "단계",
"railSettings": "설정",
"collapseRail": "사이드 패널 접기",
"expandRail": "사이드 패널 펼치기",
"addStep": "단계 추가",
"railWindow": "기간",
"railBreakdown": "분류",
"where": "조건",
"alert": "알림",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "패널은 현재의 단계, 필터 및 명명된 이벤트 규칙을 그대로 유지하며, 이 페이지의 환경, 버전 또는 비교 대상 앱 없이 전체 앱에 적용됩니다. 현재 시점의 이 앱의 앱 필터를 사용하므로 필터가 변경되면 다시 생성하세요.",
"needsNewer": "이 퍼널은 최신 버전이 필요합니다",
"step": "단계",
"ofFirst": "1단계 대비",
"ofPrevious": "이전 단계 기준",
"ofPreviousTooltip": "이 단계에 도달한 이전 단계의 비율",
"ofFirst": "1단계 전환율",
"ofPrevious": "이전 단계 전환율",
"ofPreviousTooltip": "위 단계에서 이 단계에 도달한 비율",
"ofFirstTooltip": "이 단계에 도달한 1단계의 비율. 빗금 친 부분: 제외 단계로 제거됨",
"ofFirstCompareTooltip": "1단계에서 이 단계에 도달한 비율입니다. 채워진 막대: 이번 기간. 빗금 막대: 이전 기간.",
"unitTooltip": "이 단계에 도달한 {unit}",
"timeFromFirst": "1단계부터 소요 시간",
"timeTooltip": "1단계 이벤트에서 이 단계까지 순차적으로 도달하는 최단 시간({unit} 기준)",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "이탈 이유 보기",
"droppedAria": "{step}단계 이후 {count}개 이탈({pct}), 사이드 패널에서 이유를 확인하세요",
"noneDropped": "0개 이탈",
"droppedCompare": "이전 기간 {prevPct}({prevCount}) 대비 {pct} 이탈({count})",
"droppedCompareAria": "단계 {step} 이후 이탈: 이전 기간 {prevPct}({prevCount}) 대비 {pct} 이탈({count}). 사이드 패널에서 이유를 확인하세요.",
"droppedCompareTooltip": "이번 기간({range}): {outOf} {unit} 중 {pct}. 이전 기간({prevRange}): {prevOutOf} {unit} 중 {prevPct}. 이번 기간의 이탈을 엽니다.",
"compareTooLong": "비교는 최대 31일 범위까지 지원됩니다. 비교하려면 더 짧은 범위를 선택하세요.",
"compareTo": "비교 대상",
"compareOff": "사용 안 함",
"comparePrev": "이전 기간",
"compareAgo1h": "1시간 전",
"compareAgo1d": "1일 전",
"compareAgo1w": "1주 전",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "이 범위와 겹침",
"compareSameAsPrev": "이전 기간과 동일",
"compareNoLineAbout": "비교선이 없는 이유",
"compareNoLineBreakdown": "분할이 켜져 있으면 차트에 비교선이 없습니다: {field}의 각 값이 개별 선으로 표시됩니다. 상단 요약은 여전히 전체 퍼널을 {range}와 비교합니다.",
"thisPeriod": "이번 기간",
"changeHeader": "변화(pt)",
"changeHeaderTooltip": "위 단계로부터의 전환에 대한 퍼센트 포인트 변화.",
"changePts": "{arrow}{value}pt",
"lowVolume": "≈ 데이터 부족",
"changeTooltip": "95% 구간: ± {ci}pt. 이번 기간은 {cur} {unit} 중, 이전 기간은 {prev} 중.",
"previousFailed": "이전 기간을 불러올 수 없습니다",
"previousCount": "{count} 대비",
"viewSteps": "단계",
"viewTrend": "추세",
"trendPairOption": "단계 {a} → 단계 {b}",
"trendFrom": "전환 시작",
"trendTo": "전환 대상",
"trendLastStep": "{step} (마지막 단계)",
"trendFromHere": "이 단계부터 전환 추세 표시",
"trendInterval": "간격",
"intervalAuto": "자동",
"intervalAutoResolved": "자동 ({interval})",
"intervalDay": "일",
"intervalWeek": "주",
"intervalMonth": "월",
"intervalNameDay": "일",
"intervalNameWeek": "주",
"intervalNameMonth": "월",
"trendAxis": "전환율 (%)",
"trendNoUnits": "{unit} 없음",
"trendNoDataBefore": "{date} 이전 데이터 없음",
"trendArriving": "데이터가 아직 도착 중",
"trendStillConverting": "아직 전환 중",
"trendStillConvertingToday": "아직 전환 중 (오늘)",
"trendStillConvertingTooltip": "이 {unit} 중 일부는 아직 {step}단계에 도달할 수 있으므로 이 비율은 상승할 수 있습니다.",
"trendLowVolume": "볼륨 낮음",
"trendLowVolumeTooltip": "{n}개 {unit} 미만이 진입했으므로 각 항목에 따라 이 비율이 크게 변동합니다.",
"trendPartial": "일부",
"trendPartialTooltip": "이 범위는 {interval}의 일부만 포함합니다.",
"trendCaption": "{range} · {tz}",
"trendEntered": "{n}단계 진입",
"trendReached": "{n}단계 도달",
"trendPrevSameDay": "이전 기간, 같은 날",
"trendPrevSameWeek": "이전 기간, 같은 주",
"trendPrevSameMonth": "이전 기간, 같은 달",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · {unit} 없음",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "이 범위에 {unit} 없음",
"trendEmptyDescription": "이 날짜 사이에는 {step}단계에 도달한 사람이 없습니다. 더 넓은 범위를 선택해 보세요.",
"trendTooShort": "시간 경과에 따른 전환을 보려면 2일 이상의 범위를 선택하세요",
"trendTooMany": "시간 경과에 따른 전환을 보려면 더 짧은 범위 또는 더 긴 간격을 선택하세요",
"trendAverage": "평균 전환율",
"trendAverageTooltip": "전환이 마감된 모든 {interval}을 합산한 값이며, 아직 전환 중인 항목은 제외됩니다.",
"trendTooltipRate": "{rate} ({reached} / {entered})",
"trendTooltipPrev": "이전 기간, {date}: {rate}",
"alertTrend": "알림을 설정하려면 단계로 전환하세요",
"overallVs": "{pct} 대비 ({range})",
"overallVsAgo": "{pct} 대비 {label} ({range})",
"compareEmpty": "비교할 {unit} 없음 ({range})",
"breakdownPrevTooltip": "{last} / {first} {unit} · {range}",
"nextStep": "{step}단계 이후 가장 흔한 다음 단계:",
"searchStep": "페이지 및 클릭 검색",
"searchStepAll": "페이지, 클릭, 이벤트 및 오류 검색",
@@ -2261,6 +2333,9 @@
"atLeast": "이상",
"notSet": "(설정되지 않음)",
"other": "기타",
"leftBefore": "{step}단계 전 이탈",
"reachedNoValue": "{step}단계 도달, 값 없음",
"breakdownFieldMissing": "{field} 필드는 이 앱 데이터에 없으므로 모든 {unit}이(가) 설정되지 않은 것으로 표시됩니다.",
"total": "총계",
"conversion": "전환",
"usersFallback": "이 링크는 사용자 수를 집계하지만, 이 앱과 범위에서 사용자 신원을 확인할 수 없어 세션 수를 집계합니다.",
@@ -2273,17 +2348,20 @@
"pickFirst": "또는 페이지 및 클릭에서 1단계를 선택하세요",
"overall": "전체 전환율",
"overallOf": "{first} {unit} 중 {last}",
"overallTime": "· 전환까지 중앙값 {median} (p90 {p90})",
"countBy": "집계 기준",
"sessions": "세션",
"sessionNoun": "세션",
"windowSession": "동일 세션",
"window1h": "1시간 이내",
"window1d": "1일 이내",
"window7d": "7일 이내",
"noBreakdown": "분류 없음",
"breakdownLabel": "분류",
"breakdownNone": "없음",
"windowSession": "동일 세션 · 한 번의 방문",
"windowSessionShort": "동일 세션",
"window1h": "1시간",
"window1d": "1일",
"window7d": "7일",
"windowOfStep1": "{window} · 1단계 기준",
"whoFinishIn": "단계를 완료한 사용자",
"viewAs": "보기 형식",
"headlineWithin": "1단계로부터 {window} 이내",
"headlineMedian": "전환까지 중앙값 {value}",
"headlineEstimated": "추정",
"clearSteps": "단계 지우기",
"dims": {
"browser": "브라우저",
@@ -2303,7 +2381,6 @@
"usersExact": "사용자 모드는 정확하게 실행되며 이 범위에서는 느릴 수 있습니다.",
"step1EmptyTitle": "이 범위에서 1단계에 도달한 {unit}이(가) 없습니다",
"step1EmptyDescription": "이 범위에서 {step}에 도달한 사용자가 없습니다. 범위를 넓히거나, 대신 상위 페이지에서 시작하세요.",
"breakdownTimeHidden": "전환 소요 시간은 분류가 없는 퍼널에서 표시됩니다.",
"alertMe": "전환율이 오늘보다 낮아지면 알림 받기",
"alertNeedsRun": "알림을 설정하려면 두 개 이상의 단계로 퍼널을 구성하세요",
"alertWaiting": "퍼널 집계가 끝날 때까지 기다리세요",
@@ -2371,7 +2448,64 @@
"filterRejected": "이 값은 필터로 사용할 수 없습니다. 각 값은 1~1024자이며, 최대 20개입니다.",
"whyEstimated": "추정치: 이 줄들은 진입 수와 정확히 일치하지 않을 수 있습니다.",
"tooLongForLink": "이 단계 때문에 퍼널이 너무 길어져 링크에 담을 수 없습니다. 먼저 단계를 줄이거나 제거하세요",
"editTooLongForLink": "이 변경으로 인해 퍼널이 너무 길어져 링크에 유지할 수 없습니다. 먼저 단계, 필터 또는 제외를 제거하세요."
"editTooLongForLink": "이 변경으로 인해 퍼널이 너무 길어져 링크에 유지할 수 없습니다. 먼저 단계, 필터 또는 제외를 제거하세요.",
"breakdownRemove": "분류 기준 제거",
"breakdownAdd": "분류 기준 추가",
"breakdownCaption": "브라우저 또는 결제 수단과 같은 하나의 필드로 각 단계를 분류합니다",
"breakdownOneAtATime": "한 번에 하나의 분류 기준만",
"breakdownReplace": "분류 기준을 {field}(으)로 교체",
"breakdownValueFrom": "값 출처",
"pickerOnSteps": "이 단계에서",
"pickerKeyFields": "주요 필드",
"pickerUser": "사용자",
"pickerAllFields": "모든 필드",
"pickerStepHint": "단계 {step} · {values}",
"pickerStepHintMore": "단계 {step} · {values} +{more}",
"pickerManyValues": "단계 {step} · 값이 많음",
"pickerUserReason": "사용자 필드는 필터에만 제공됩니다",
"pickerSampling": "이 퍼널의 단계에서 필드를 읽는 중…",
"pickerSampleFailed": "이 퍼널의 단계에서 필드를 읽을 수 없습니다",
"fromStep": "단계 {step}부터",
"fromAnyStep": "모든 단계에서 · {pct} 설정됨",
"fromAutomatic": "자동 (단계 {step})",
"fromAutomaticNone": "자동",
"fromStepItem": "단계 {step} · {name}",
"stepMenu": "단계 작업",
"breakDownByStep": "이 단계의 속성으로 분류",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "값이 많음",
"breakdownBy": "{field} 기준",
"headlineBreakdown": "{field} 기준",
"breakdownAbout": "이 분류 정보",
"breakdownAboutAuto": "{field} 필드는 단계 {step}({name})에서 처음 설정되며, 해당 단계에 도달한 {reached} {unit} 중 {covered}에서 설정되므로 비율은 단계 {step}부터 시작합니다.",
"breakdownAboutPinned": "{field} 필드는 해당 필드에 선택된 단계인 단계 {step}({name})에서 읽히며, 해당 단계에 도달한 {reached} {unit} 중 {covered}에 값이 있으므로 비율은 단계 {step}부터 시작합니다.",
"breakdownAboutAny": "{field} 필드는 {reached} {unit} 중 {covered}에서 설정되며, 이 필드에 도달한 대상의 절반은 어느 단계에서도 설정되지 않으므로 비율은 단계 1부터 시작합니다.",
"breakdownAboutLeft": "단계 {step} 이전에 이탈한 대상은 값이 없으며 하나의 행으로 집계됩니다.",
"conversionFromStep": "단계 {step}부터의 전환율",
"medianTime": "중앙값 시간",
"lowVolumeTag": "낮은 볼륨",
"lowVolumeNote": "단계 {step}에서 {count} {unit} 미만인 값은 낮은 볼륨으로 표시됩니다",
"propertyTitle": "속성",
"propertyField": "필드",
"propertyAddValue": "값",
"propertyAdd": "속성 추가",
"propertyRemove": "속성 제거",
"propertyInvalid": "유효하지 않은 패턴",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "이 단계에서 확인된 필드가 없습니다",
"propertyFieldsLoading": "이 단계의 필드를 읽는 중…",
"propertyFieldsFailed": "이 단계의 필드를 읽을 수 없습니다",
"propConditions": {
"in": "같음",
"notIn": "같지 않음",
"contains": "포함",
"regex": "일치"
},
"filterNotice": "{opened} {unit} 중 {kept}을(를) 유지합니다.",
"filterNoticeAction": "{field} 기준으로 분류",
"filterNoticeWhy": "모든 단계가 100%로 표시되는 이유",
"filterNoticeReason": "모든 단계 필터는 {unit} 전체를 유지하거나 제거합니다. 단계 1에 진입한 {opened} {unit} 중 {missing}의 경우 {field}을(를) 설정한 적이 없으므로 단계 1 이전에 제거되어 초기 단계가 100%로 표시될 수 있습니다.",
"pickerOnStep": "단계 {step}에서"
},
"viewer": {
"droppedAfter": "{step}단계 후 이탈: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "퍼널: {first} → {last}",
"funnelTrendTitle": "시간 경과에 따른 전환율: {first} → {last}",
"step": "단계",
"add": "대시보드에 추가",
"funnelOnly": "대시보드는 분류가 없는 세션 퍼널을 지원합니다",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Een sessie die dit tussen de twee stappen deed, stopt met tellen bij de tweede stap.",
"addExclusionStep": "Uitsluitingsstap toevoegen",
"railSteps": "Stappen",
"railSettings": "Instellingen",
"collapseRail": "Zijpaneel invouwen",
"expandRail": "Zijpaneel uitvouwen",
"addStep": "Stap toevoegen",
"railWindow": "Venster",
"railBreakdown": "Uitsplitsing",
"where": "waar",
"alert": "Alarm",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Het paneel behoudt deze stappen, filters en regels voor benoemde events zoals ze nu zijn, voor de hele app zonder de env, versie of vergeleken apps van deze pagina. Gebruikt de app-filters van deze app zoals ze nu zijn; maak het opnieuw aan nadat ze veranderen.",
"needsNewer": "Deze funnel heeft een nieuwere versie nodig",
"step": "Stap",
"ofFirst": "Van stap 1",
"ofPrevious": "Van vorige",
"ofPreviousTooltip": "Aandeel van de vorige stap dat deze bereikte",
"ofFirst": "Conversie vanaf stap 1",
"ofPrevious": "Conversie vanaf de vorige stap",
"ofPreviousTooltip": "Aandeel van de stap hierboven dat deze stap bereikte",
"ofFirstTooltip": "Aandeel van stap 1 dat deze stap bereikte. Gearceerd deel: verwijderd door uitsluitingsstappen",
"ofFirstCompareTooltip": "Aandeel van stap 1 dat deze stap bereikte. Effen balk: deze periode. Gearceerde balk: vorige periode.",
"unitTooltip": "{unit} die deze stap bereikte",
"timeFromFirst": "Tijd vanaf stap 1",
"timeTooltip": "Kortste tijd in volgorde van een gebeurtenis in stap 1 tot deze stap, per {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Bekijk waarom ze afvielen",
"droppedAria": "{count} afgevallen ({pct}) na stap {step}, bekijk waarom in een zijpaneel",
"noneDropped": "0 afgevallen",
"droppedCompare": "{pct} afgevallen ({count}) t.o.v. {prevPct} ({prevCount}) in de vorige periode",
"droppedCompareAria": "{pct} afgevallen ({count}) t.o.v. {prevPct} ({prevCount}) in de vorige periode, na stap {step}, zie waarom in een zijpaneel",
"droppedCompareTooltip": "Deze periode, {range}: {pct} van {outOf} {unit}. Vorige periode, {prevRange}: {prevPct} van {prevOutOf} {unit}. Opent de uitval van deze periode.",
"compareTooLong": "Vergelijken werkt voor bereiken tot 31 dagen. Kies een korter bereik om te vergelijken.",
"compareTo": "Vergelijken met",
"compareOff": "Uit",
"comparePrev": "Vorige periode",
"compareAgo1h": "1 uur geleden",
"compareAgo1d": "1 dag geleden",
"compareAgo1w": "1 week geleden",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "overlapt dit bereik",
"compareSameAsPrev": "hetzelfde als de vorige periode",
"compareNoLineAbout": "Over de ontbrekende vergelijkingslijn",
"compareNoLineBreakdown": "De grafiek heeft geen vergelijkingslijn terwijl er een uitsplitsing actief is: elke waarde van {field} is een eigen lijn. De kopregel vergelijkt nog steeds de hele funnel met {range}.",
"thisPeriod": "Deze periode",
"changeHeader": "Wijziging (pp)",
"changeHeaderTooltip": "Wijziging in procentpunten, in de conversie vanaf de vorige stap.",
"changePts": "{arrow}{value} pp",
"lowVolume": "≈ weinig volume",
"changeTooltip": "95%-interval: ± {ci} pp. Van de {cur} {unit} deze periode en {prev} vorige periode.",
"previousFailed": "De vorige periode kon niet worden geladen",
"previousCount": "t.o.v. {count}",
"viewSteps": "Stappen",
"viewTrend": "Trend",
"trendPairOption": "Stap {a} → Stap {b}",
"trendFrom": "Conversie vanaf",
"trendTo": "naar",
"trendLastStep": "{step} (laatste stap)",
"trendFromHere": "Toon conversietrend vanaf deze stap",
"trendInterval": "Interval",
"intervalAuto": "Auto",
"intervalAutoResolved": "Auto ({interval})",
"intervalDay": "Dag",
"intervalWeek": "Week",
"intervalMonth": "Maand",
"intervalNameDay": "dag",
"intervalNameWeek": "week",
"intervalNameMonth": "maand",
"trendAxis": "Conversie (%)",
"trendNoUnits": "Geen {unit}",
"trendNoDataBefore": "Geen gegevens vóór {date}",
"trendArriving": "Gegevens komen nog binnen",
"trendStillConverting": "Conversie loopt nog",
"trendStillConvertingToday": "Conversie loopt nog (vandaag)",
"trendStillConvertingTooltip": "Sommige van deze {unit} kunnen stap {step} nog bereiken, dus dit percentage kan stijgen.",
"trendLowVolume": "Laag volume",
"trendLowVolumeTooltip": "Fewer than {n} {unit} entered, so this rate moves a lot with each one.",
"trendPartial": "Gedeeltelijk",
"trendPartialTooltip": "Het bereik dekt slechts een deel van deze {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Stap {n} betreden",
"trendReached": "Stap {n} bereikt",
"trendPrevSameDay": "Vorige periode, dezelfde dag",
"trendPrevSameWeek": "Vorige periode, dezelfde week",
"trendPrevSameMonth": "Vorige periode, dezelfde maand",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · geen {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Geen {unit} in dit bereik",
"trendEmptyDescription": "Niemand bereikte stap {step} tussen deze datums. Probeer een breder bereik.",
"trendTooShort": "Kies een bereik van minstens 2 dagen om conversie in de tijd te zien",
"trendTooMany": "Kies een korter bereik of een langer interval om conversie in de tijd te zien",
"trendAverage": "Gemiddelde conversie",
"trendAverageTooltip": "Opgeteld over elke {interval} waarvan de conversie is afgesloten; nog lopende conversies blijven buiten beschouwing.",
"trendTooltipRate": "{rate} ({reached} van {entered})",
"trendTooltipPrev": "Vorige periode, {date}: {rate}",
"alertTrend": "Schakel over naar Stappen om een alarm in te stellen",
"overallVs": "t.o.v. {pct} in {range}",
"overallVsAgo": "t.o.v. {pct} {label} ({range})",
"compareEmpty": "Geen {unit} in {range} om mee te vergelijken",
"breakdownPrevTooltip": "{last} van {first} {unit} in {range}",
"nextStep": "Volgende stap, meest voorkomend na stap {step}:",
"searchStep": "Pagina's en kliks zoeken",
"searchStepAll": "Zoek pagina's, kliks, events en fouten",
@@ -2261,6 +2333,9 @@
"atLeast": "minimaal",
"notSet": "(niet ingesteld)",
"other": "Overig",
"leftBefore": "Afgehaakt vóór stap {step}",
"reachedNoValue": "Stap {step} bereikt, geen waarde",
"breakdownFieldMissing": "{field} is geen veld in de gegevens van deze app, dus alle {unit} worden als niet ingesteld beschouwd.",
"total": "Totaal",
"conversion": "Conversie",
"usersFallback": "Deze link telt gebruikers, maar er is geen gebruikersidentiteit te herleiden voor deze app en dit bereik, dus worden sessies geteld.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Of kies stap 1 uit pagina's en kliks",
"overall": "Algehele conversie",
"overallOf": "{last} van {first} {unit}",
"overallTime": "· mediaan {median} om te converteren (p90 {p90})",
"countBy": "Tellen op",
"sessions": "Sessies",
"sessionNoun": "sessies",
"windowSession": "Dezelfde sessie",
"window1h": "Binnen 1 uur",
"window1d": "Binnen 1 dag",
"window7d": "Binnen 7 dagen",
"noBreakdown": "Geen uitsplitsing",
"breakdownLabel": "Uitsplitsing",
"breakdownNone": "Geen",
"windowSession": "dezelfde sessie · één bezoek",
"windowSessionShort": "dezelfde sessie",
"window1h": "1 uur",
"window1d": "1 dag",
"window7d": "7 dagen",
"windowOfStep1": "{window} · van stap 1",
"whoFinishIn": "die de stappen voltooien binnen",
"viewAs": "Weergeven als",
"headlineWithin": "binnen {window} van stap 1",
"headlineMedian": "mediaan {value} om te converteren",
"headlineEstimated": "geschat.",
"clearSteps": "Stappen wissen",
"dims": {
"browser": "Browser",
@@ -2303,7 +2381,6 @@
"usersExact": "De modus Gebruikers werkt exact en kan traag zijn in dit bereik.",
"step1EmptyTitle": "Geen {unit} bereikte stap 1 in dit bereik",
"step1EmptyDescription": "Niemand bereikte {step} in dit bereik. Verbreed het bereik of begin in plaats daarvan vanaf een toppagina.",
"breakdownTimeHidden": "Tijd tot conversie wordt getoond voor de funnel zonder uitsplitsing.",
"alertMe": "Waarschuw me wanneer de conversie onder die van vandaag zakt",
"alertNeedsRun": "Bouw een funnel met twee of meer stappen om er een alarm op te zetten",
"alertWaiting": "Wacht tot de funnel klaar is met tellen",
@@ -2371,7 +2448,64 @@
"filterRejected": "Deze waarden kunnen niet als filter worden gebruikt. Elke waarde is 1 tot 1024 tekens, maximaal 20.",
"whyEstimated": "Geschat: deze regels tellen mogelijk niet exact op tot het aantal binnenkomsten.",
"tooLongForLink": "Deze stap maakt de funnel te lang om in de link te bewaren; verkort of verwijder eerst een stap",
"editTooLongForLink": "Deze wijziging maakt de funnel te lang om in de link te bewaren; verwijder eerst een stap, filter of uitsluiting"
"editTooLongForLink": "Deze wijziging maakt de funnel te lang om in de link te bewaren; verwijder eerst een stap, filter of uitsluiting",
"breakdownRemove": "Uitsplitsing verwijderen",
"breakdownAdd": "Uitsplitsing toevoegen",
"breakdownCaption": "Splits elke stap op één veld, zoals browser of betaalmethode",
"breakdownOneAtATime": "Eén uitsplitsing tegelijk",
"breakdownReplace": "Uitsplitsing vervangen door {field}",
"breakdownValueFrom": "Waarde van",
"pickerOnSteps": "Op deze stappen",
"pickerKeyFields": "Sleutelvelden",
"pickerUser": "Gebruiker",
"pickerAllFields": "Alle velden",
"pickerStepHint": "Stap {step} · {values}",
"pickerStepHintMore": "Stap {step} · {values} +{more}",
"pickerManyValues": "Stap {step} · veel waarden",
"pickerUserReason": "Gebruikersvelden worden alleen voor filters aangeboden",
"pickerSampling": "Velden op de stappen van deze funnel worden gelezen…",
"pickerSampleFailed": "Kon de velden op de stappen van deze funnel niet lezen",
"fromStep": "Vanaf stap {step}",
"fromAnyStep": "Vanaf elke stap · {pct} ingesteld",
"fromAutomatic": "Automatisch (stap {step})",
"fromAutomaticNone": "Automatisch",
"fromStepItem": "Stap {step} · {name}",
"stepMenu": "Stapacties",
"breakDownByStep": "Uitsplitsen naar een eigenschap van deze stap",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Veel waarden",
"breakdownBy": "Per {field}",
"headlineBreakdown": "per {field}",
"breakdownAbout": "Over deze uitsplitsing",
"breakdownAboutAuto": "{field} wordt het eerst ingesteld bij stap {step}, {name}, in {covered} van de {reached} {unit} die die stap bereikten, dus de percentages beginnen bij stap {step}.",
"breakdownAboutPinned": "{field} wordt gelezen bij stap {step}, {name}, de stap die ervoor is gekozen; {covered} van de {reached} {unit} die die stap bereikten hebben een waarde, dus de percentages beginnen bij stap {step}.",
"breakdownAboutAny": "{field} is ingesteld op {covered} van de {reached} {unit}, en voor de helft van degenen die het bereiken op geen enkele stap, dus de percentages beginnen bij stap 1.",
"breakdownAboutLeft": "Degenen die vóór stap {step} zijn afgehaakt, hebben geen waarde en worden in één rij geteld.",
"conversionFromStep": "Conversie vanaf stap {step}",
"medianTime": "Mediane tijd",
"lowVolumeTag": "Laag volume",
"lowVolumeNote": "Waarden onder {count} {unit} bij stap {step} worden als laag volume gemarkeerd",
"propertyTitle": "Eigenschap",
"propertyField": "Veld",
"propertyAddValue": "Waarde",
"propertyAdd": "Eigenschap toevoegen",
"propertyRemove": "Eigenschap verwijderen",
"propertyInvalid": "Geen geldig patroon",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Geen velden gezien op deze stap",
"propertyFieldsLoading": "Velden van deze stap worden gelezen…",
"propertyFieldsFailed": "Kon de velden van deze stap niet lezen",
"propConditions": {
"in": "is",
"notIn": "is niet",
"contains": "bevat",
"regex": "komt overeen met"
},
"filterNotice": "Houdt {kept} van {opened} {unit} over.",
"filterNoticeAction": "Uitsplitsen naar {field}",
"filterNoticeWhy": "Waarom elke stap 100% toont",
"filterNoticeReason": "Filter alle stappen behoudt of verwijdert hele {unit}. {missing} van de {opened} {unit} die stap 1 openden, hebben {field} nooit ingesteld, dus ze vallen vóór stap 1 af en de vroege stappen kunnen 100% tonen.",
"pickerOnStep": "Op stap {step}"
},
"viewer": {
"droppedAfter": "Afgehaakt na stap {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Funnel: {first} → {last}",
"funnelTrendTitle": "Conversie in de tijd: {first} → {last}",
"step": "Stap",
"add": "Toevoegen aan dashboard",
"funnelOnly": "Dashboards ondersteunen Sessions-funnels zonder uitsplitsing",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Sesja, która zrobiła to między dwoma krokami, przestaje być liczona na drugim kroku.",
"addExclusionStep": "Dodaj krok wykluczenia",
"railSteps": "Kroki",
"railSettings": "Ustawienia",
"collapseRail": "Zwiń panel boczny",
"expandRail": "Rozwiń panel boczny",
"addStep": "Dodaj krok",
"railWindow": "Okno",
"railBreakdown": "Podział",
"where": "gdzie",
"alert": "Alert",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Panel zachowuje te kroki, filtry i reguły zdarzeń nazwanych w obecnej postaci, dla całej aplikacji bez środowiska, wersji ani porównywanych aplikacji z tej strony. Korzysta z bieżących filtrów aplikacji; utwórz go ponownie po ich zmianie.",
"needsNewer": "Ten lejek wymaga nowszej wersji",
"step": "Krok",
"ofFirst": "Kroku 1",
"ofPrevious": "Z poprzedniego",
"ofPreviousTooltip": "Udział poprzedniego kroku, który dotarł do tego kroku",
"ofFirst": "Konwersja z kroku 1",
"ofPrevious": "Konwersja z poprzedniego kroku",
"ofPreviousTooltip": "Odsetek użytkowników z poprzedniego kroku, którzy dotarli do tego kroku",
"ofFirstTooltip": "Udział kroku 1, który dotarł do tego kroku. Część zakreskowana: usunięta przez kroki wykluczające",
"ofFirstCompareTooltip": "Odsetek użytkowników z kroku 1, którzy dotarli do tego kroku. Słupek pełny: ten okres. Słupek kreskowany: poprzedni okres.",
"unitTooltip": "{unit}, które dotarły do tego kroku",
"timeFromFirst": "Czas od kroku 1",
"timeTooltip": "Najkrótszy czas w kolejności od zdarzenia kroku 1 do tego kroku, na {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Zobacz, dlaczego je porzucono",
"droppedAria": "porzucone: {count} ({pct}) po kroku {step}, zobacz dlaczego w panelu bocznym",
"noneDropped": "0 porzuconych",
"droppedCompare": "{pct} odpadło ({count}) vs {prevPct} ({prevCount}) w poprzednim okresie",
"droppedCompareAria": "{pct} odpadło ({count}) vs {prevPct} ({prevCount}) w poprzednim okresie, po kroku {step}, zobacz przyczynę w panelu bocznym",
"droppedCompareTooltip": "Ten okres, {range}: {pct} z {outOf} {unit}. Poprzedni okres, {prevRange}: {prevPct} z {prevOutOf} {unit}. Otwiera szczegóły odpływu w tym okresie.",
"compareTooLong": "Porównywanie działa dla zakresów do 31 dni. Wybierz krótszy zakres, aby porównać.",
"compareTo": "Porównaj z",
"compareOff": "Wyłączone",
"comparePrev": "Poprzedni okres",
"compareAgo1h": "1 godzinę temu",
"compareAgo1d": "1 dzień temu",
"compareAgo1w": "1 tydzień temu",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "nakłada się na ten zakres",
"compareSameAsPrev": "taki sam jak w poprzednim okresie",
"compareNoLineAbout": "O brakującej linii porównania",
"compareNoLineBreakdown": "Gdy włączony jest podział, wykres nie ma linii porównania: każda wartość pola {field} ma własną linię. Główny wskaźnik nadal porównuje cały lej z zakresem {range}.",
"thisPeriod": "Ten okres",
"changeHeader": "Zmiana (pkt)",
"changeHeaderTooltip": "Zmiana w punktach procentowych w konwersji z poprzedniego kroku.",
"changePts": "{arrow}{value} pkt",
"lowVolume": "≈ niski wolumen",
"changeTooltip": "Przedział 95%: ± {ci} pkt. Spośród {cur} {unit} w tym okresie i {prev} w poprzednim okresie.",
"previousFailed": "Nie udało się wczytać poprzedniego okresu",
"previousCount": "wobec {count}",
"viewSteps": "Kroki",
"viewTrend": "Trend",
"trendPairOption": "Krok {a} → Krok {b}",
"trendFrom": "Konwersja z",
"trendTo": "do",
"trendLastStep": "{step} (ostatni krok)",
"trendFromHere": "Pokaż trend konwersji od tego kroku",
"trendInterval": "Interwał",
"intervalAuto": "Auto",
"intervalAutoResolved": "Auto ({interval})",
"intervalDay": "Dzień",
"intervalWeek": "Tydzień",
"intervalMonth": "Miesiąc",
"intervalNameDay": "dzień",
"intervalNameWeek": "tydzień",
"intervalNameMonth": "miesiąc",
"trendAxis": "Konwersja (%)",
"trendNoUnits": "Brak {unit}",
"trendNoDataBefore": "Brak danych przed {date}",
"trendArriving": "Dane wciąż napływają",
"trendStillConverting": "Konwersja w toku",
"trendStillConvertingToday": "Konwersja w toku (dziś)",
"trendStillConvertingTooltip": "Niektóre {unit} mogą jeszcze osiągnąć krok {step}, więc ten odsetek może wzrosnąć.",
"trendLowVolume": "Niski wolumen",
"trendLowVolumeTooltip": "Weszło mniej niż {n} {unit}, więc ten odsetek mocno się waha przy każdym kolejnym przypadku.",
"trendPartial": "Częściowy",
"trendPartialTooltip": "Zakres obejmuje tylko część tego {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Weszło do kroku {n}",
"trendReached": "Osiągnięto krok {n}",
"trendPrevSameDay": "Poprzedni okres, ten sam dzień",
"trendPrevSameWeek": "Poprzedni okres, ten sam tydzień",
"trendPrevSameMonth": "Poprzedni okres, ten sam miesiąc",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · brak {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Brak {unit} w tym zakresie",
"trendEmptyDescription": "Nikt nie osiągnął kroku {step} między tymi datami. Spróbuj szerszego zakresu.",
"trendTooShort": "Wybierz zakres co najmniej 2 dni, aby zobaczyć konwersję w czasie",
"trendTooMany": "Wybierz krótszy zakres lub dłuższy interwał, aby zobaczyć konwersję w czasie",
"trendAverage": "Średnia konwersja",
"trendAverageTooltip": "Zsumowane po wszystkich okresach {interval}, których konwersja się zakończyła; nadal trwające są pomijane.",
"trendTooltipRate": "{rate} ({reached} z {entered})",
"trendTooltipPrev": "Poprzedni okres, {date}: {rate}",
"alertTrend": "Przełącz na Kroki, aby ustawić alert",
"overallVs": "wobec {pct} w {range}",
"overallVsAgo": "wobec {pct} {label} ({range})",
"compareEmpty": "Brak {unit} w {range} do porównania",
"breakdownPrevTooltip": "{last} z {first} {unit} w {range}",
"nextStep": "Następny krok, najczęstszy po kroku {step}:",
"searchStep": "Szukaj stron i kliknięć",
"searchStepAll": "Szukaj stron, kliknięć, zdarzeń i błędów",
@@ -2261,6 +2333,9 @@
"atLeast": "co najmniej",
"notSet": "(nie ustawiono)",
"other": "Inne",
"leftBefore": "Odeszło przed krokiem {step}",
"reachedNoValue": "Osiągnięto krok {step}, brak wartości",
"breakdownFieldMissing": "{field} nie jest polem w danych tej aplikacji, więc wszystkie {unit} są traktowane jako nieustawione.",
"total": "Razem",
"conversion": "Konwersja",
"usersFallback": "Ten link zlicza użytkowników, ale dla tej aplikacji i zakresu nie można ustalić tożsamości użytkownika, więc zlicza sesje.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Lub wybierz krok 1 ze stron i kliknięć",
"overall": "Ogólna konwersja",
"overallOf": "{last} z {first} {unit}",
"overallTime": "· mediana {median} do konwersji (p90 {p90})",
"countBy": "Zliczaj według",
"sessions": "Sesje",
"sessionNoun": "sesje",
"windowSession": "Ta sama sesja",
"window1h": "W ciągu 1 godziny",
"window1d": "W ciągu 1 dnia",
"window7d": "W ciągu 7 dni",
"noBreakdown": "Bez podziału",
"breakdownLabel": "Rozbicie",
"breakdownNone": "Brak",
"windowSession": "ta sama sesja · jedna wizyta",
"windowSessionShort": "ta sama sesja",
"window1h": "1 godzina",
"window1d": "1 dzień",
"window7d": "7 dni",
"windowOfStep1": "{window} · od kroku 1",
"whoFinishIn": "którzy kończą kroki w",
"viewAs": "Wyświetl jako",
"headlineWithin": "w ciągu {window} od kroku 1",
"headlineMedian": "mediana {value} do konwersji",
"headlineEstimated": "szac.",
"clearSteps": "Wyczyść kroki",
"dims": {
"browser": "Przeglądarka",
@@ -2303,7 +2381,6 @@
"usersExact": "Tryb użytkowników działa dokładnie i może być wolny w tym zakresie.",
"step1EmptyTitle": "Żadne {unit} nie osiągnęły kroku 1 w tym zakresie",
"step1EmptyDescription": "Nikt nie dotarł do {step} w tym zakresie. Rozszerz zakres lub zacznij od najpopularniejszej strony.",
"breakdownTimeHidden": "Czas konwersji jest pokazywany dla lejka bez podziału.",
"alertMe": "Powiadom mnie, gdy konwersja spadnie poniżej dzisiejszej wartości",
"alertNeedsRun": "Zbuduj lejek o co najmniej dwóch krokach, aby ustawić alert",
"alertWaiting": "Poczekaj, aż lejek zakończy zliczanie",
@@ -2371,7 +2448,64 @@
"filterRejected": "Tych wartości nie można użyć jako filtra. Każda wartość ma od 1 do 1024 znaków, maksymalnie 20.",
"whyEstimated": "Szacowane: te wiersze mogą nie sumować się dokładnie do liczby wejść.",
"tooLongForLink": "Ten krok sprawia, że lejek jest zbyt długi, aby zmieścić go w linku; najpierw skróć lub usuń jakiś krok",
"editTooLongForLink": "Ta zmiana wydłuża lejek tak, że nie zmieści się w jego linku; najpierw usuń krok, filtr lub wykluczenie"
"editTooLongForLink": "Ta zmiana wydłuża lejek tak, że nie zmieści się w jego linku; najpierw usuń krok, filtr lub wykluczenie",
"breakdownRemove": "Usuń podział",
"breakdownAdd": "Dodaj podział",
"breakdownCaption": "Podziel każdy krok według jednego pola, np. przeglądarki lub metody płatności",
"breakdownOneAtATime": "Jeden podział naraz",
"breakdownReplace": "Zastąp podział polem {field}",
"breakdownValueFrom": "Wartość z",
"pickerOnSteps": "Na tych krokach",
"pickerKeyFields": "Kluczowe pola",
"pickerUser": "Użytkownik",
"pickerAllFields": "Wszystkie pola",
"pickerStepHint": "Krok {step} · {values}",
"pickerStepHintMore": "Krok {step} · {values} +{more}",
"pickerManyValues": "Krok {step} · wiele wartości",
"pickerUserReason": "Pola użytkownika są dostępne tylko w filtrach",
"pickerSampling": "Odczytywanie pól kroków tego lejka…",
"pickerSampleFailed": "Nie udało się odczytać pól kroków tego lejka",
"fromStep": "Od kroku {step}",
"fromAnyStep": "Z dowolnego kroku · {pct} ustawione",
"fromAutomatic": "Automatycznie (krok {step})",
"fromAutomaticNone": "Automatycznie",
"fromStepItem": "Krok {step} · {name}",
"stepMenu": "Akcje kroku",
"breakDownByStep": "Podziel według właściwości tego kroku",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Wiele wartości",
"breakdownBy": "Według {field}",
"headlineBreakdown": "według {field}",
"breakdownAbout": "O tym podziale",
"breakdownAboutAuto": "{field} jest po raz pierwszy ustawiane na kroku {step}, {name}, w przypadku {covered} z {reached} {unit}, które go osiągnęły, więc jego wskaźniki zaczynają się na kroku {step}.",
"breakdownAboutPinned": "{field} jest odczytywane na kroku {step}, {name}, kroku wybranym dla niego; w przypadku {covered} z {reached} {unit}, które go osiągnęły, wartość jest ustawiona, więc jego wskaźniki zaczynają się na kroku {step}.",
"breakdownAboutAny": "{field} jest ustawiane w przypadku {covered} z {reached} {unit}, a w przypadku połowy tych, które do niego docierają, nie występuje na żadnym kroku, więc jego wskaźniki zaczynają się od kroku 1.",
"breakdownAboutLeft": "Ci, którzy odeszli przed krokiem {step}, nie mają wartości i są liczeni w jednym wierszu.",
"conversionFromStep": "Konwersja od kroku {step}",
"medianTime": "Mediana czasu",
"lowVolumeTag": "Niski wolumen",
"lowVolumeNote": "Wartości poniżej {count} {unit} na kroku {step} są oznaczane jako niski wolumen",
"propertyTitle": "Właściwość",
"propertyField": "Pole",
"propertyAddValue": "Wartość",
"propertyAdd": "Dodaj właściwość",
"propertyRemove": "Usuń właściwość",
"propertyInvalid": "Nieprawidłowy wzorzec",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Nie wykryto pól na tym kroku",
"propertyFieldsLoading": "Odczytywanie pól tego kroku…",
"propertyFieldsFailed": "Nie udało się odczytać pól tego kroku",
"propConditions": {
"in": "jest",
"notIn": "nie jest",
"contains": "zawiera",
"regex": "pasuje"
},
"filterNotice": "Zachowuje {kept} z {opened} {unit}.",
"filterNoticeAction": "Podziel według {field}",
"filterNoticeWhy": "Dlaczego każdy krok pokazuje 100%",
"filterNoticeReason": "Filtr wszystkich kroków zachowuje lub odrzuca {unit} w całości. {missing} z {opened} {unit} rozpoczynających krok 1 nigdy nie ustawiło pola {field}, więc są odrzucane przed krokiem 1 i wczesne kroki mogą pokazywać 100%.",
"pickerOnStep": "Na kroku {step}"
},
"viewer": {
"droppedAfter": "Porzucono po kroku {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Lejek: {first} → {last}",
"funnelTrendTitle": "Konwersja w czasie: {first} → {last}",
"step": "Krok",
"add": "Dodaj do pulpitu",
"funnelOnly": "Pulpity obsługują lejki sesji bez podziału",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Uma sessão que fez isso entre as duas etapas para de contar na segunda etapa.",
"addExclusionStep": "Adicionar etapa de exclusão",
"railSteps": "Etapas",
"railSettings": "Configurações",
"collapseRail": "Recolher painel lateral",
"expandRail": "Expandir painel lateral",
"addStep": "Adicionar etapa",
"railWindow": "Janela",
"railBreakdown": "Detalhamento",
"where": "onde",
"alert": "Alerta",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "O painel mantém estas etapas, filtros e regras de eventos nomeados como estão agora, para todo o aplicativo, sem o ambiente, a versão ou os aplicativos comparados desta página. Usa os filtros de aplicativo deste aplicativo como estão agora; recrie-o depois que eles mudarem.",
"needsNewer": "Este funil precisa de uma versão mais recente",
"step": "Etapa",
"ofFirst": "Da etapa 1",
"ofPrevious": "Da anterior",
"ofPreviousTooltip": "Percentual da etapa anterior que chegou a esta",
"ofFirst": "Conversão a partir do passo 1",
"ofPrevious": "Conversão a partir do passo anterior",
"ofPreviousTooltip": "Percentagem do passo anterior que chegou a este passo",
"ofFirstTooltip": "Percentual da etapa 1 que chegou a esta etapa. Parte hachurada: removida pelas etapas de exclusão",
"ofFirstCompareTooltip": "Percentagem do passo 1 que chegou a este passo. Barra sólida: este período. Barra hachurada: período anterior.",
"unitTooltip": "{unit} que chegaram a esta etapa",
"timeFromFirst": "Tempo desde a etapa 1",
"timeTooltip": "Tempo mais curto em ordem de um evento da etapa 1 até esta etapa, por {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Veja porque abandonaram",
"droppedAria": "{count} abandonaram ({pct}) após a etapa {step}, veja o motivo num painel lateral",
"noneDropped": "0 abandonaram",
"droppedCompare": "Abandono de {pct} ({count}) vs {prevPct} ({prevCount}) no período anterior",
"droppedCompareAria": "Abandono de {pct} ({count}) vs {prevPct} ({prevCount}) no período anterior, após o passo {step}; consulte o motivo no painel lateral",
"droppedCompareTooltip": "Este período, {range}: {pct} de {outOf} {unit}. Período anterior, {prevRange}: {prevPct} de {prevOutOf} {unit}. Abre o abandono deste período.",
"compareTooLong": "A comparação funciona para intervalos até 31 dias. Escolha um intervalo mais curto para comparar.",
"compareTo": "Comparar com",
"compareOff": "Desativado",
"comparePrev": "Período anterior",
"compareAgo1h": "Há 1 hora",
"compareAgo1d": "Há 1 dia",
"compareAgo1w": "Há 1 semana",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "sobrepõe-se a este intervalo",
"compareSameAsPrev": "igual ao período anterior",
"compareNoLineAbout": "Sobre a linha de comparação em falta",
"compareNoLineBreakdown": "O gráfico não tem linha de comparação enquanto uma desagregação está ativa: cada valor de {field} tem a sua própria linha. O valor principal continua a comparar o funil completo com {range}.",
"thisPeriod": "Este período",
"changeHeader": "Variação (pp)",
"changeHeaderTooltip": "Variação em pontos percentuais na conversão a partir do passo anterior.",
"changePts": "{arrow}{value} pp",
"lowVolume": "≈ baixo volume",
"changeTooltip": "Intervalo de 95%: ± {ci} pp. De {cur} {unit} neste período e {prev} no período anterior.",
"previousFailed": "Não foi possível carregar o período anterior",
"previousCount": "vs {count}",
"viewSteps": "Passos",
"viewTrend": "Tendência",
"trendPairOption": "Passo {a} → Passo {b}",
"trendFrom": "Conversão de",
"trendTo": "para",
"trendLastStep": "{step} (último passo)",
"trendFromHere": "Mostrar tendência de conversão a partir deste passo",
"trendInterval": "Intervalo",
"intervalAuto": "Automático",
"intervalAutoResolved": "Automático ({interval})",
"intervalDay": "Dia",
"intervalWeek": "Semana",
"intervalMonth": "Mês",
"intervalNameDay": "dia",
"intervalNameWeek": "semana",
"intervalNameMonth": "mês",
"trendAxis": "Conversão (%)",
"trendNoUnits": "Sem {unit}",
"trendNoDataBefore": "Sem dados antes de {date}",
"trendArriving": "Dados ainda a chegar",
"trendStillConverting": "Ainda em conversão",
"trendStillConvertingToday": "Ainda em conversão (hoje)",
"trendStillConvertingTooltip": "Há {unit} que ainda podem chegar ao passo {step}, por isso esta taxa pode aumentar.",
"trendLowVolume": "Volume baixo",
"trendLowVolumeTooltip": "Entraram menos de {n} {unit}, por isso esta taxa varia muito de caso para caso.",
"trendPartial": "Parcial",
"trendPartialTooltip": "O intervalo cobre apenas parte deste {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Entrou no passo {n}",
"trendReached": "Alcançou o passo {n}",
"trendPrevSameDay": "Período anterior, mesmo dia",
"trendPrevSameWeek": "Período anterior, mesma semana",
"trendPrevSameMonth": "Período anterior, mesmo mês",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · sem {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Sem {unit} neste intervalo",
"trendEmptyDescription": "Ninguém alcançou o passo {step} entre estas datas. Experimente um período maior.",
"trendTooShort": "Escolha um período de pelo menos 2 dias para ver a conversão ao longo do tempo",
"trendTooMany": "Escolha um período mais curto ou um intervalo maior para ver a conversão ao longo do tempo",
"trendAverage": "Conversão média",
"trendAverageTooltip": "Soma de cada {interval} cuja conversão já terminou; os que ainda estão a converter ficam de fora.",
"trendTooltipRate": "{rate} ({reached} de {entered})",
"trendTooltipPrev": "Período anterior, {date}: {rate}",
"alertTrend": "Mude para Passos para definir um alerta",
"overallVs": "vs {pct} em {range}",
"overallVsAgo": "vs {pct} {label} ({range})",
"compareEmpty": "Sem {unit} em {range} para comparar",
"breakdownPrevTooltip": "{last} de {first} {unit} em {range}",
"nextStep": "Próxima etapa, mais comum após a etapa {step}:",
"searchStep": "Pesquisar páginas e cliques",
"searchStepAll": "Pesquisar páginas, cliques, eventos e erros",
@@ -2261,6 +2333,9 @@
"atLeast": "pelo menos",
"notSet": "(não definido)",
"other": "Outro",
"leftBefore": "Saiu antes do passo {step}",
"reachedNoValue": "Alcançou o passo {step}, sem valor",
"breakdownFieldMissing": "{field} não é um campo dos dados desta aplicação, pelo que {unit} aparecem sem valor definido.",
"total": "Total",
"conversion": "Conversão",
"usersFallback": "Este link conta utilizadores, mas nenhuma identidade de utilizador é resolvida para esta aplicação e intervalo, pelo que conta sessões.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Ou escolha a etapa 1 em páginas e cliques",
"overall": "Conversão geral",
"overallOf": "{last} de {first} {unit}",
"overallTime": "· mediana {median} para converter (p90 {p90})",
"countBy": "Contar por",
"sessions": "Sessões",
"sessionNoun": "sessões",
"windowSession": "Mesma sessão",
"window1h": "No prazo de 1 hora",
"window1d": "No prazo de 1 dia",
"window7d": "No prazo de 7 dias",
"noBreakdown": "Sem detalhamento",
"breakdownLabel": "Detalhamento",
"breakdownNone": "Nenhum",
"windowSession": "a mesma sessão · uma visita",
"windowSessionShort": "a mesma sessão",
"window1h": "1 hora",
"window1d": "1 dia",
"window7d": "7 dias",
"windowOfStep1": "{window} · do passo 1",
"whoFinishIn": "quem conclui os passos em",
"viewAs": "Ver como",
"headlineWithin": "dentro de {window} do passo 1",
"headlineMedian": "mediana de {value} até à conversão",
"headlineEstimated": "est.",
"clearSteps": "Limpar etapas",
"dims": {
"browser": "Navegador",
@@ -2303,7 +2381,6 @@
"usersExact": "O modo Utilizadores é executado de forma exata e pode ser lento neste intervalo.",
"step1EmptyTitle": "Nenhum {unit} alcançou a etapa 1 neste intervalo",
"step1EmptyDescription": "Ninguém alcançou {step} neste intervalo. Amplie o intervalo ou comece por uma página principal.",
"breakdownTimeHidden": "O tempo para conversão é exibido para o funil sem detalhamento.",
"alertMe": "Alertar-me quando a conversão ficar abaixo da de hoje",
"alertNeedsRun": "Crie um funil com duas ou mais etapas para alertar sobre ele",
"alertWaiting": "Aguarde que o funil termine a contagem",
@@ -2371,7 +2448,64 @@
"filterRejected": "Estes valores não podem ser usados como filtro. Cada valor tem de 1 a 1024 caracteres, no máximo 20.",
"whyEstimated": "Estimado: estas linhas podem não somar exatamente a contagem de entradas.",
"tooLongForLink": "Esta etapa deixa o funil longo demais para caber no link; encurte ou remova uma etapa primeiro",
"editTooLongForLink": "Esta alteração deixa o funil longo demais para caber no link; remova uma etapa, um filtro ou uma exclusão primeiro"
"editTooLongForLink": "Esta alteração deixa o funil longo demais para caber no link; remova uma etapa, um filtro ou uma exclusão primeiro",
"breakdownRemove": "Remover desagregação",
"breakdownAdd": "Adicionar desagregação",
"breakdownCaption": "Divida cada passo por um campo, como o navegador ou o método de pagamento",
"breakdownOneAtATime": "Uma desagregação de cada vez",
"breakdownReplace": "Substituir a desagregação por {field}",
"breakdownValueFrom": "Valor de",
"pickerOnSteps": "Nestes passos",
"pickerKeyFields": "Campos principais",
"pickerUser": "Utilizador",
"pickerAllFields": "Todos os campos",
"pickerStepHint": "Passo {step} · {values}",
"pickerStepHintMore": "Passo {step} · {values} +{more}",
"pickerManyValues": "Passo {step} · muitos valores",
"pickerUserReason": "Os campos de utilizador só estão disponíveis para filtros",
"pickerSampling": "A ler os campos dos passos deste funil…",
"pickerSampleFailed": "Não foi possível ler os campos dos passos deste funil",
"fromStep": "A partir do passo {step}",
"fromAnyStep": "A partir de qualquer passo · {pct} definido",
"fromAutomatic": "Automático (passo {step})",
"fromAutomaticNone": "Automático",
"fromStepItem": "Passo {step} · {name}",
"stepMenu": "Ações do passo",
"breakDownByStep": "Desagregar por uma propriedade deste passo",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Muitos valores",
"breakdownBy": "Por {field}",
"headlineBreakdown": "por {field}",
"breakdownAbout": "Sobre esta desagregação",
"breakdownAboutAuto": "{field} é definido pela primeira vez no passo {step}, {name}, em {covered} dos {reached} {unit} que o alcançaram, pelo que as suas taxas começam no passo {step}.",
"breakdownAboutPinned": "{field} é lido no passo {step}, {name}, o passo selecionado para este campo; {covered} dos {reached} {unit} que o alcançaram têm um valor, pelo que as suas taxas começam no passo {step}.",
"breakdownAboutAny": "{field} está definido em {covered} dos {reached} {unit} e, para metade dos que o alcançam, não está definido em nenhum passo, pelo que as suas taxas começam no passo 1.",
"breakdownAboutLeft": "Os que saíram antes do passo {step} não têm valor e são contados numa única linha.",
"conversionFromStep": "Conversão a partir do passo {step}",
"medianTime": "Tempo mediano",
"lowVolumeTag": "Volume baixo",
"lowVolumeNote": "Os valores abaixo de {count} {unit} no passo {step} são marcados como volume baixo",
"propertyTitle": "Propriedade",
"propertyField": "Campo",
"propertyAddValue": "Valor",
"propertyAdd": "Adicionar propriedade",
"propertyRemove": "Remover propriedade",
"propertyInvalid": "Padrão inválido",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Nenhum campo encontrado neste passo",
"propertyFieldsLoading": "A ler os campos deste passo…",
"propertyFieldsFailed": "Não foi possível ler os campos deste passo",
"propConditions": {
"in": "é",
"notIn": "não é",
"contains": "contém",
"regex": "corresponde a"
},
"filterNotice": "Mantém {kept} de {opened} {unit}.",
"filterNoticeAction": "Desagregar por {field}",
"filterNoticeWhy": "Porque é que todos os passos mostram 100%",
"filterNoticeReason": "Filtrar todos os passos mantém ou descarta {unit} por inteiro. {missing} dos {opened} {unit} que abriram o passo 1 nunca definiram {field}, pelo que ficam de fora antes do passo 1 e os primeiros passos podem apresentar 100%.",
"pickerOnStep": "No passo {step}"
},
"viewer": {
"droppedAfter": "Abandonou após a etapa {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Funil: {first} → {last}",
"funnelTrendTitle": "Conversão ao longo do tempo: {first} → {last}",
"step": "Etapa",
"add": "Adicionar ao dashboard",
"funnelOnly": "Dashboards suportam funis de Sessões sem detalhamento",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Сессия, совершившая это между двумя шагами, перестаёт учитываться на втором шаге.",
"addExclusionStep": "Добавить шаг исключения",
"railSteps": "Шаги",
"railSettings": "Настройки",
"collapseRail": "Свернуть боковую панель",
"expandRail": "Развернуть боковую панель",
"addStep": "Добавить шаг",
"railWindow": "Окно",
"railBreakdown": "Разбивка",
"where": "где",
"alert": "Оповещение",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Панель сохраняет эти шаги, фильтры и правила именованных событий в текущем виде для всего приложения, без окружения, версии и сравниваемых приложений этой страницы. Использует текущие фильтры приложения; пересоздайте её после их изменения.",
"needsNewer": "Для этой воронки нужна более новая версия",
"step": "Шаг",
"ofFirst": "От шага 1",
"ofPrevious": "От предыдущего",
"ofPreviousTooltip": "Доля предыдущего шага, дошедшая до этого",
"ofFirst": "Конверсия с шага 1",
"ofPrevious": "Конверсия с предыдущего шага",
"ofPreviousTooltip": "Доля перешедших с предыдущего шага, достигших этого шага",
"ofFirstTooltip": "Доля шага 1, дошедшая до этого шага. Заштрихованная часть: отсечена шагами-исключениями",
"ofFirstCompareTooltip": "Доля от шага 1, дошедшая до этого шага. Сплошная полоса: текущий период. Полоса со штриховкой: предыдущий период.",
"unitTooltip": "{unit}, дошедшие до этого шага",
"timeFromFirst": "Время от шага 1",
"timeTooltip": "Кратчайшее время в порядке следования от события шага 1 до этого шага, в расчёте на {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Узнать, почему они не дошли",
"droppedAria": "{count} не дошли ({pct}) после шага {step}, причину смотрите на боковой панели",
"noneDropped": "0 не дошли",
"droppedCompare": "{pct} отсеялось ({count}) против {prevPct} ({prevCount}) в предыдущем периоде",
"droppedCompareAria": "{pct} отсеялось ({count}) против {prevPct} ({prevCount}) в предыдущем периоде после шага {step}, причины — в боковой панели",
"droppedCompareTooltip": "Текущий период, {range}: {pct} из {outOf} {unit}. Предыдущий период, {prevRange}: {prevPct} из {prevOutOf} {unit}. Открывает данные об отсеве за текущий период.",
"compareTooLong": "Сравнение работает для диапазонов до 31 дня. Выберите более короткий диапазон для сравнения.",
"compareTo": "Сравнить с",
"compareOff": "Выкл.",
"comparePrev": "Предыдущий период",
"compareAgo1h": "1 час назад",
"compareAgo1d": "1 день назад",
"compareAgo1w": "1 неделю назад",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "пересекается с этим диапазоном",
"compareSameAsPrev": "совпадает с предыдущим периодом",
"compareNoLineAbout": "О недостающей линии сравнения",
"compareNoLineBreakdown": "На графике нет линии сравнения, пока включена разбивка: каждое значение {field} — отдельная линия. Итоговый показатель по-прежнему сравнивает всю воронку с {range}.",
"thisPeriod": "Текущий период",
"changeHeader": "Изменение (п.п.)",
"changeHeaderTooltip": "Изменение в процентных пунктах для конверсии с предыдущего шага.",
"changePts": "{arrow}{value} п.п.",
"lowVolume": "≈ низкий объём",
"changeTooltip": "95%-й интервал: ± {ci} п.п. Из {cur} {unit} в текущем периоде и {prev} в предыдущем периоде.",
"previousFailed": "Не удалось загрузить предыдущий период",
"previousCount": "против {count}",
"viewSteps": "Шаги",
"viewTrend": "Динамика",
"trendPairOption": "Шаг {a} → Шаг {b}",
"trendFrom": "Конверсия с",
"trendTo": "по",
"trendLastStep": "{step} (последний шаг)",
"trendFromHere": "Показать динамику конверсии с этого шага",
"trendInterval": "Интервал",
"intervalAuto": "Авто",
"intervalAutoResolved": "Авто ({interval})",
"intervalDay": "День",
"intervalWeek": "Неделя",
"intervalMonth": "Месяц",
"intervalNameDay": "день",
"intervalNameWeek": "неделя",
"intervalNameMonth": "месяц",
"trendAxis": "Конверсия (%)",
"trendNoUnits": "Нет {unit}",
"trendNoDataBefore": "Нет данных до {date}",
"trendArriving": "Данные ещё поступают",
"trendStillConverting": "Конверсия продолжается",
"trendStillConvertingToday": "Конверсия продолжается (сегодня)",
"trendStillConvertingTooltip": "Некоторые из этих {unit} всё ещё могут достичь шага {step}, поэтому этот показатель может вырасти.",
"trendLowVolume": "Малый объём",
"trendLowVolumeTooltip": "Вошло меньше {n} {unit}, поэтому показатель сильно меняется с каждым.",
"trendPartial": "Частично",
"trendPartialTooltip": "Диапазон охватывает только часть периода ({interval}).",
"trendCaption": "{range} · {tz}",
"trendEntered": "Вошли на шаг {n}",
"trendReached": "Достигли шага {n}",
"trendPrevSameDay": "Предыдущий период, тот же день",
"trendPrevSameWeek": "Предыдущий период, та же неделя",
"trendPrevSameMonth": "Предыдущий период, тот же месяц",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · нет {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "В этом диапазоне нет {unit}",
"trendEmptyDescription": "За этот период никто не достиг шага {step}. Попробуйте расширить диапазон.",
"trendTooShort": "Выберите диапазон не менее 2 дней, чтобы увидеть конверсию во времени",
"trendTooMany": "Выберите диапазон покороче или интервал подольше, чтобы увидеть конверсию во времени",
"trendAverage": "Средняя конверсия",
"trendAverageTooltip": "Суммируется за каждый {interval}, где конверсия завершена; ещё не завершённые не учитываются.",
"trendTooltipRate": "{rate} ({reached} из {entered})",
"trendTooltipPrev": "Предыдущий период, {date}: {rate}",
"alertTrend": "Переключитесь на «Шаги», чтобы настроить оповещение",
"overallVs": "по сравнению с {pct} в {range}",
"overallVsAgo": "по сравнению с {pct} {label} ({range})",
"compareEmpty": "В {range} нет {unit} для сравнения",
"breakdownPrevTooltip": "{last} из {first} {unit} в {range}",
"nextStep": "Следующий шаг, самый частый после шага {step}:",
"searchStep": "Поиск по страницам и кликам",
"searchStepAll": "Поиск страниц, кликов, событий и ошибок",
@@ -2261,6 +2333,9 @@
"atLeast": "не менее",
"notSet": "(не задано)",
"other": "Прочее",
"leftBefore": "Ушли до шага {step}",
"reachedNoValue": "Достигли шага {step}, нет значения",
"breakdownFieldMissing": "{field} не является полем в данных этого приложения, поэтому для всех {unit} значение считается не заданным.",
"total": "Всего",
"conversion": "Конверсия",
"usersFallback": "Эта ссылка считает пользователей, но для этого приложения и диапазона идентичность пользователя не определяется, поэтому учитываются сессии.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Или выберите шаг 1 из страниц и кликов",
"overall": "Общая конверсия",
"overallOf": "{last} из {first} {unit}",
"overallTime": "· медиана {median} до конверсии (p90 {p90})",
"countBy": "Считать по",
"sessions": "Сессии",
"sessionNoun": "сессий",
"windowSession": "Та же сессия",
"window1h": "В течение 1 часа",
"window1d": "В течение 1 дня",
"window7d": "В течение 7 дней",
"noBreakdown": "Без разбивки",
"breakdownLabel": "Разбивка",
"breakdownNone": "Нет",
"windowSession": "та же сессия · один визит",
"windowSessionShort": "та же сессия",
"window1h": "1 час",
"window1d": "1 день",
"window7d": "7 дней",
"windowOfStep1": "{window} · от шага 1",
"whoFinishIn": "кто завершает шаги за",
"viewAs": "Отображать как",
"headlineWithin": "в течение {window} после шага 1",
"headlineMedian": "медиана {value} до конверсии",
"headlineEstimated": "прибл.",
"clearSteps": "Очистить шаги",
"dims": {
"browser": "Браузер",
@@ -2303,7 +2381,6 @@
"usersExact": "Режим пользователей работает точно и может быть медленным на этом диапазоне.",
"step1EmptyTitle": "{unit} не достигли шага 1 в этом диапазоне",
"step1EmptyDescription": "Никто не достиг {step} в этом диапазоне. Расширьте диапазон или начните с одной из топовых страниц.",
"breakdownTimeHidden": "Время до конверсии показывается для воронки без разбивки.",
"alertMe": "Оповестить меня, когда конверсия упадёт ниже сегодняшней",
"alertNeedsRun": "Постройте воронку из двух или более шагов, чтобы настроить по ней оповещение",
"alertWaiting": "Дождитесь завершения подсчёта воронки",
@@ -2371,7 +2448,64 @@
"filterRejected": "Эти значения нельзя использовать как фильтр. Каждое значение — от 1 до 1024 символов, всего не более 20.",
"whyEstimated": "Оценка: сумма этих строк может не совпадать точно с числом входов.",
"tooLongForLink": "Из-за этого шага воронка становится слишком длинной для сохранения в ссылке; сначала сократите или удалите шаг",
"editTooLongForLink": "Из-за этого изменения воронка станет слишком длинной для ссылки; сначала удалите шаг, фильтр или исключение"
"editTooLongForLink": "Из-за этого изменения воронка станет слишком длинной для ссылки; сначала удалите шаг, фильтр или исключение",
"breakdownRemove": "Удалить разбивку",
"breakdownAdd": "Добавить разбивку",
"breakdownCaption": "Разбейте каждый шаг по одному полю, например по браузеру или способу оплаты",
"breakdownOneAtATime": "По одной разбивке за раз",
"breakdownReplace": "Заменить разбивку на {field}",
"breakdownValueFrom": "Значение из",
"pickerOnSteps": "На этих шагах",
"pickerKeyFields": "Ключевые поля",
"pickerUser": "Пользователь",
"pickerAllFields": "Все поля",
"pickerStepHint": "Шаг {step} · {values}",
"pickerStepHintMore": "Шаг {step} · {values} +{more}",
"pickerManyValues": "Шаг {step} · много значений",
"pickerUserReason": "Пользовательские поля предлагаются только для фильтров",
"pickerSampling": "Чтение полей на шагах этой воронки…",
"pickerSampleFailed": "Не удалось прочитать поля на шагах этой воронки",
"fromStep": "С шага {step}",
"fromAnyStep": "С любого шага · {pct} задано",
"fromAutomatic": "Автоматически (шаг {step})",
"fromAutomaticNone": "Автоматически",
"fromStepItem": "Шаг {step} · {name}",
"stepMenu": "Действия шага",
"breakDownByStep": "Разбить по свойству этого шага",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Много значений",
"breakdownBy": "По {field}",
"headlineBreakdown": "по {field}",
"breakdownAbout": "Об этой разбивке",
"breakdownAboutAuto": "{field} впервые задаётся на шаге {step}, {name}, в {covered} из {reached} {unit}, дошедших до него, поэтому его показатели начинаются с шага {step}.",
"breakdownAboutPinned": "{field} читается на шаге {step}, {name}, выбранном для него; {covered} из {reached} {unit}, дошедших до него, содержат значение, поэтому его показатели начинаются с шага {step}.",
"breakdownAboutAny": "{field} задаётся у {covered} из {reached} {unit}, а у половины дошедших до него — ни на одном шаге, поэтому его показатели начинаются с шага 1.",
"breakdownAboutLeft": "Те, кто ушёл до шага {step}, не имеют значения и учитываются в одной строке.",
"conversionFromStep": "Конверсия с шага {step}",
"medianTime": "Медианное время",
"lowVolumeTag": "Малый объём",
"lowVolumeNote": "Значения ниже {count} {unit} на шаге {step} помечаются как малый объём",
"propertyTitle": "Свойство",
"propertyField": "Поле",
"propertyAddValue": "Значение",
"propertyAdd": "Добавить свойство",
"propertyRemove": "Удалить свойство",
"propertyInvalid": "Недопустимый шаблон",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "На этом шаге не найдено полей",
"propertyFieldsLoading": "Чтение полей этого шага…",
"propertyFieldsFailed": "Не удалось прочитать поля этого шага",
"propConditions": {
"in": "равно",
"notIn": "не равно",
"contains": "содержит",
"regex": "соответствует"
},
"filterNotice": "Оставляет {kept} из {opened} {unit}.",
"filterNoticeAction": "Разбить по {field}",
"filterNoticeWhy": "Почему каждый шаг показывает 100%",
"filterNoticeReason": "Фильтр по всем шагам оставляет или отбрасывает {unit} целиком. {missing} из {opened} {unit}, начавших шаг 1, ни разу не задали {field}, поэтому они отбрасываются до шага 1, и ранние шаги могут показывать 100%.",
"pickerOnStep": "На шаге {step}"
},
"viewer": {
"droppedAfter": "Выбытие после шага {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Воронка: {first} → {last}",
"funnelTrendTitle": "Конверсия во времени: {first} → {last}",
"step": "Шаг",
"add": "Добавить на дашборд",
"funnelOnly": "Дашборды поддерживают воронки сессий без разбивки",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "İki adım arasında bunu yapan bir oturum, ikinci adımda sayılmamaya başlar.",
"addExclusionStep": "Hariç tutma adımı ekle",
"railSteps": "Adımlar",
"railSettings": "Ayarlar",
"collapseRail": "Yan paneli daralt",
"expandRail": "Yan paneli genişlet",
"addStep": "Adım ekle",
"railWindow": "Pencere",
"railBreakdown": "Kırılım",
"where": "burada",
"alert": "Uyarı",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Panel bu adımları, filtreleri ve adlandırılmış olay kurallarını şu andaki halleriyle, bu sayfanın ortamı, sürümü veya karşılaştırılan uygulamaları olmadan tüm uygulama için saklar. Bu uygulamanın uygulama filtrelerini şu andaki haliyle kullanır; değiştikten sonra yeniden oluşturun.",
"needsNewer": "Bu huni daha yeni bir sürüm gerektiriyor",
"step": "Adım",
"ofFirst": "1. adıma göre",
"ofPrevious": "Öncekinden",
"ofPreviousTooltip": "Bu adıma ulaşan önceki adımın payı",
"ofFirst": "1. adımdan dönüşüm",
"ofPrevious": "Önceki adımdan dönüşüm",
"ofPreviousTooltip": "Bir üst adımdan bu adıma ulaşanların oranı",
"ofFirstTooltip": "1. adımdan bu adıma ulaşanların payı. Taralı kısım: dışlama adımlarıyla kaldırılanlar",
"ofFirstCompareTooltip": "1. adımdan bu adıma ulaşanların oranı. Dolu çubuk: bu dönem. Taralı çubuk: önceki dönem.",
"unitTooltip": "bu adıma ulaşan {unit}",
"timeFromFirst": "1. adımdan itibaren süre",
"timeTooltip": "Bir 1. adım olayından bu adıma kadar en kısa sıralı süre, {unit} başına",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Neden düştüklerini görün",
"droppedAria": "{step}. adımdan sonra {count} düştü ({pct}), nedenini yan panelde görün",
"noneDropped": "0 düştü",
"droppedCompare": "{pct} bırakma ({count}) — önceki dönem {prevPct} ({prevCount})",
"droppedCompareAria": "{pct} bırakma ({count}) — önceki dönem {prevPct} ({prevCount}), {step}. adımdan sonra; nedenini yan panelde görün",
"droppedCompareTooltip": "Bu dönem, {range}: {outOf} {unit} içinden {pct}. Önceki dönem, {prevRange}: {prevOutOf} {unit} içinden {prevPct}. Bu dönemin bırakma detaylarını açar.",
"compareTooLong": "Karşılaştırma 31 güne kadar olan aralıklarda çalışır. Karşılaştırmak için daha kısa bir aralık seçin.",
"compareTo": "Şununla karşılaştır",
"compareOff": "Kapalı",
"comparePrev": "Önceki dönem",
"compareAgo1h": "1 saat önce",
"compareAgo1d": "1 gün önce",
"compareAgo1w": "1 hafta önce",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "bu aralıkla çakışıyor",
"compareSameAsPrev": "önceki dönemle aynı",
"compareNoLineAbout": "Eksik karşılaştırma çizgisi hakkında",
"compareNoLineBreakdown": "Kırılım açıkken grafikte karşılaştırma çizgisi yoktur: {field} alanının her değeri kendi çizgisidir. Ana gösterge yine de tüm huniyi {range} ile karşılaştırır.",
"thisPeriod": "Bu dönem",
"changeHeader": "Değişim (puan)",
"changeHeaderTooltip": "Bir üst adımdan dönüşümde yüzde puan cinsinden değişim",
"changePts": "{arrow}{value} puan",
"lowVolume": "≈ düşük hacim",
"changeTooltip": "%95 aralık: ± {ci} puan. Bu dönem {cur} {unit}, önceki dönem {prev} üzerinden.",
"previousFailed": "Önceki dönem yüklenemedi",
"previousCount": "{count} karşısında",
"viewSteps": "Adımlar",
"viewTrend": "Eğilim",
"trendPairOption": "Adım {a} → Adım {b}",
"trendFrom": "Dönüşüm",
"trendTo": "→",
"trendLastStep": "{step} (son adım)",
"trendFromHere": "Bu adımdan dönüşüm eğilimini göster",
"trendInterval": "Zaman aralığı",
"intervalAuto": "Otomatik",
"intervalAutoResolved": "Otomatik ({interval})",
"intervalDay": "Gün",
"intervalWeek": "Hafta",
"intervalMonth": "Ay",
"intervalNameDay": "gün",
"intervalNameWeek": "hafta",
"intervalNameMonth": "ay",
"trendAxis": "Dönüşüm (%)",
"trendNoUnits": "{unit} yok",
"trendNoDataBefore": "{date} öncesine ait veri yok",
"trendArriving": "Veri hâlâ geliyor",
"trendStillConverting": "Dönüşüm sürüyor",
"trendStillConvertingToday": "Dönüşüm sürüyor (bugün)",
"trendStillConvertingTooltip": "Bu {unit} içinden bazıları hâlâ {step}. adıma ulaşabilir; bu nedenle bu oran yükselebilir.",
"trendLowVolume": "Düşük hacim",
"trendLowVolumeTooltip": "{n} {unit} değerinden daha az giriş olduğu için bu oran her biriyle çok dalgalanır.",
"trendPartial": "Kısmi",
"trendPartialTooltip": "Aralık bu {interval} diliminin yalnızca bir kısmını kapsıyor.",
"trendCaption": "{range} · {tz}",
"trendEntered": "{n}. adıma girenler",
"trendReached": "{n}. adıma ulaşanlar",
"trendPrevSameDay": "Önceki dönem, aynı gün",
"trendPrevSameWeek": "Önceki dönem, aynı hafta",
"trendPrevSameMonth": "Önceki dönem, aynı ay",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · {unit} yok",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Bu aralıkta {unit} yok",
"trendEmptyDescription": "Bu tarihler arasında {step}. adıma kimse ulaşmadı. Daha geniş bir aralık deneyin.",
"trendTooShort": "Dönüşümün zaman içindeki değişimini görmek için en az 2 günlük bir aralık seçin",
"trendTooMany": "Dönüşümün zaman içindeki değişimini görmek için daha kısa bir zaman aralığı veya daha uzun bir gruplama aralığı seçin",
"trendAverage": "Ortalama dönüşüm",
"trendAverageTooltip": "Dönüşümü kapanmış her {interval} dilimi üzerinden toplanır; hâlâ dönüşenler dışarıda bırakılır.",
"trendTooltipRate": "{rate} ({reached} / {entered})",
"trendTooltipPrev": "Önceki dönem, {date}: {rate}",
"alertTrend": "Uyarı ayarlamak için Adımlar'a geçin",
"overallVs": "{pct} ile karşılaştırıldığında, {range} içinde",
"overallVsAgo": "{pct} {label} değerine kıyasla ({range})",
"compareEmpty": "Karşılaştırılacak {unit} yok: {range}",
"breakdownPrevTooltip": "{last}, {first} {unit} içinden ({range})",
"nextStep": "Sonraki adım, {step}. adımdan sonra en yaygın:",
"searchStep": "Sayfaları ve tıklamaları ara",
"searchStepAll": "Sayfaları, tıklamaları, olayları ve hataları ara",
@@ -2261,6 +2333,9 @@
"atLeast": "en az",
"notSet": "(ayarlanmadı)",
"other": "Diğer",
"leftBefore": "{step}. adımdan önce ayrılan",
"reachedNoValue": "{step}. adıma ulaştı, değer yok",
"breakdownFieldMissing": "{field}, bu uygulamanın verilerinde bir alan değil; bu nedenle tüm {unit} ayarlanmamış olarak görünür.",
"total": "Toplam",
"conversion": "Dönüşüm",
"usersFallback": "Bu bağlantı kullanıcıları sayar, ancak bu uygulama ve aralık için hiçbir kullanıcı kimliği çözümlenemiyor, bu nedenle oturumları sayar.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Veya 1. adımı sayfalar ve tıklamalar arasından seçin",
"overall": "Genel dönüşüm",
"overallOf": "{first} {unit} içinden {last}",
"overallTime": "· dönüşüm için medyan {median} (p90 {p90})",
"countBy": "Sayım ölçütü",
"sessions": "Oturumlar",
"sessionNoun": "oturum",
"windowSession": "Aynı oturum",
"window1h": "1 saat içinde",
"window1d": "1 gün içinde",
"window7d": "7 gün içinde",
"noBreakdown": "Kırılım yok",
"breakdownLabel": "Kırılım",
"breakdownNone": "Yok",
"windowSession": "aynı oturum · tek ziyaret",
"windowSessionShort": "aynı oturum",
"window1h": "1 saat",
"window1d": "1 gün",
"window7d": "7 gün",
"windowOfStep1": "{window} · 1. adıma göre",
"whoFinishIn": "adımları şu sürede tamamlayanlar",
"viewAs": "Görüntüleme biçimi",
"headlineWithin": "1. adımdan itibaren {window} içinde",
"headlineMedian": "dönüşüm için medyan {value}",
"headlineEstimated": "tahmini",
"clearSteps": "Adımları temizle",
"dims": {
"browser": "Tarayıcı",
@@ -2303,7 +2381,6 @@
"usersExact": "Kullanıcılar modu kesin hesaplama yapar ve bu aralıkta yavaş olabilir.",
"step1EmptyTitle": "Bu aralıkta 1. adıma ulaşan {unit} yok",
"step1EmptyDescription": "Bu aralıkta {step} adımına kimse ulaşmadı. Aralığı genişletin veya bunun yerine en üst sıradaki sayfadan başlayın.",
"breakdownTimeHidden": "Dönüşüm süresi, kırılımı olmayan huni için gösterilir.",
"alertMe": "Dönüşüm bugünkünün altına düştüğünde beni uyar",
"alertNeedsRun": "Uyarı vermek için iki veya daha fazla adımdan oluşan bir huni oluşturun",
"alertWaiting": "Huninin sayımı bitirmesini bekleyin",
@@ -2371,7 +2448,64 @@
"filterRejected": "Bu değerler filtre olarak kullanılamaz. Her değer 1 ile 1024 karakter arasındadır, en fazla 20 değer.",
"whyEstimated": "Tahmini: bu satırların toplamı giriş sayısına tam olarak uymayabilir.",
"tooLongForLink": "Bu adım huniyi bağlantısında tutulamayacak kadar uzun yapıyor; önce bir adımı kısaltın veya kaldırın",
"editTooLongForLink": "Bu değişiklik huniyi bağlantısında tutulamayacak kadar uzatıyor; önce bir adım, filtre veya hariç tutma kaldırın"
"editTooLongForLink": "Bu değişiklik huniyi bağlantısında tutulamayacak kadar uzatıyor; önce bir adım, filtre veya hariç tutma kaldırın",
"breakdownRemove": "Kırılımı kaldır",
"breakdownAdd": "Kırılım ekle",
"breakdownCaption": "Her adımı tarayıcı veya ödeme yöntemi gibi tek bir alana göre bölün",
"breakdownOneAtATime": "Aynı anda tek kırılım",
"breakdownReplace": "Kırılımı {field} ile değiştir",
"breakdownValueFrom": "Değer kaynağı",
"pickerOnSteps": "Bu adımlarda",
"pickerKeyFields": "Anahtar alanlar",
"pickerUser": "Kullanıcı",
"pickerAllFields": "Tüm alanlar",
"pickerStepHint": "Adım {step} · {values}",
"pickerStepHintMore": "Adım {step} · {values} +{more}",
"pickerManyValues": "Adım {step} · birçok değer",
"pickerUserReason": "Kullanıcı alanları yalnızca filtreler için sunulur",
"pickerSampling": "Bu huninin adımlarındaki alanlar okunuyor…",
"pickerSampleFailed": "Bu huninin adımlarındaki alanlar okunamadı",
"fromStep": "Adım {step} itibarıyla",
"fromAnyStep": "Herhangi bir adımdan · {pct} ayarlı",
"fromAutomatic": "Otomatik (adım {step})",
"fromAutomaticNone": "Otomatik",
"fromStepItem": "Adım {step} · {name}",
"stepMenu": "Adım işlemleri",
"breakDownByStep": "Bu adımın bir özelliğine göre kırılım",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Birçok değer",
"breakdownBy": "{field} ölçütüne göre",
"headlineBreakdown": "{field} bazında",
"breakdownAbout": "Bu kırılım hakkında",
"breakdownAboutAuto": "{field}, {step}. adımda ({name}) ilk kez ayarlanır; bu adıma ulaşan {covered}/{reached} {unit} içinde ayarlıdır, bu nedenle oranları {step}. adımdan başlar.",
"breakdownAboutPinned": "{field}, {step}. adımda ({name}) okunur; onun için seçilen adım budur; bu adıma ulaşan {covered}/{reached} {unit} içinde bir değer bulunur, bu nedenle oranları {step}. adımdan başlar.",
"breakdownAboutAny": "{field}, {covered}/{reached} {unit} üzerinde ayarlıdır; ona ulaşanların yarısında hiçbir adımda ayarlı değildir, bu nedenle oranları 1. adımda başlar.",
"breakdownAboutLeft": "Adım {step} öncesinde ayrılanların değeri yoktur ve tek bir satırda sayılır.",
"conversionFromStep": "Adım {step}'den dönüşüm",
"medianTime": "Medyan süre",
"lowVolumeTag": "Düşük hacim",
"lowVolumeNote": "{count} {unit} altındaki değerler, adım {step} için düşük hacim olarak işaretlenir",
"propertyTitle": "Özellik",
"propertyField": "Alan",
"propertyAddValue": "Değer",
"propertyAdd": "Özellik ekle",
"propertyRemove": "Özelliği kaldır",
"propertyInvalid": "Geçerli bir desen değil",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Bu adımda hiçbir alan görülmedi",
"propertyFieldsLoading": "Bu adımın alanları okunuyor…",
"propertyFieldsFailed": "Bu adımın alanları okunamadı",
"propConditions": {
"in": "eşittir",
"notIn": "eşit değildir",
"contains": "içerir",
"regex": "eşleşir"
},
"filterNotice": "Tutulan: {kept}/{opened} {unit}.",
"filterNoticeAction": "{field} ölçütüne göre kırılım",
"filterNoticeWhy": "Her adım neden %100 gösteriyor",
"filterNoticeReason": "Tüm adımları filtrele, tüm {unit} öğelerini tutar veya düşürür. 1. adımı açan {missing}/{opened} {unit} içinde {field} hiç ayarlanmaz; bu nedenle 1. adımdan önce düşürülürler ve erken adımlar %100 gösterebilir.",
"pickerOnStep": "Adım {step}'de"
},
"viewer": {
"droppedAfter": "{step}. adımdan sonra bırakıldı: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Huni: {first} → {last}",
"funnelTrendTitle": "Zaman içinde dönüşüm: {first} → {last}",
"step": "Adım",
"add": "Gösterge panosuna ekle",
"funnelOnly": "Gösterge panoları, kırılım olmadan Oturumlar hunilerini destekler",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "Phiên nào làm điều này giữa hai bước sẽ ngừng được tính tại bước thứ hai.",
"addExclusionStep": "Thêm bước loại trừ",
"railSteps": "Các bước",
"railSettings": "Cài đặt",
"collapseRail": "Thu gọn bảng bên",
"expandRail": "Mở rộng bảng bên",
"addStep": "Thêm bước",
"railWindow": "Khoảng thời gian",
"railBreakdown": "Phân tách",
"where": "trong đó",
"alert": "Cảnh báo",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "Bảng này giữ nguyên các bước, bộ lọc và quy tắc sự kiện được đặt tên như hiện tại, cho toàn bộ ứng dụng mà không kèm env, phiên bản hay các ứng dụng so sánh của trang này. Sử dụng bộ lọc ứng dụng của app này tại thời điểm hiện tại; hãy tạo lại sau khi chúng thay đổi.",
"needsNewer": "Phễu này cần một phiên bản mới hơn",
"step": "Bước",
"ofFirst": "Của bước 1",
"ofPrevious": "So với bước trước",
"ofPreviousTooltip": "Tỷ lệ của bước trước đã đến được bước này",
"ofFirst": "Chuyển đổi từ bước 1",
"ofPrevious": "Chuyển đổi từ bước trước",
"ofPreviousTooltip": "Tỷ lệ của bước trên đã đạt đến bước này",
"ofFirstTooltip": "Tỷ lệ của bước 1 đã đến được bước này. Phần gạch chéo: bị loại bởi các bước loại trừ",
"ofFirstCompareTooltip": "Tỷ lệ của bước 1 đã đạt đến bước này. Thanh đặc: kỳ này. Thanh có gạch chéo: kỳ trước.",
"unitTooltip": "{unit} đã đến được bước này",
"timeFromFirst": "Thời gian từ bước 1",
"timeTooltip": "Thời gian ngắn nhất theo đúng thứ tự từ một sự kiện ở bước 1 đến bước này, tính theo {unit}",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "Xem lý do họ rời bỏ",
"droppedAria": "{count} rời bỏ ({pct}) sau bước {step}, xem lý do trong khung bên",
"noneDropped": "0 rời bỏ",
"droppedCompare": "{pct} đã rời bỏ ({count}) so với {prevPct} ({prevCount}) kỳ trước",
"droppedCompareAria": "{pct} đã rời bỏ ({count}) so với {prevPct} ({prevCount}) kỳ trước, sau bước {step}, xem lý do trong bảng bên",
"droppedCompareTooltip": "Kỳ này, {range}: {pct} trên {outOf} {unit}. Kỳ trước, {prevRange}: {prevPct} trên {prevOutOf} {unit}. Mở thông tin rời bỏ của kỳ này.",
"compareTooLong": "Tính năng so sánh chỉ hoạt động với khoảng thời gian tối đa 31 ngày. Chọn khoảng thời gian ngắn hơn để so sánh.",
"compareTo": "So sánh với",
"compareOff": "Tắt",
"comparePrev": "Kỳ trước",
"compareAgo1h": "1 giờ trước",
"compareAgo1d": "1 ngày trước",
"compareAgo1w": "1 tuần trước",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "chồng lấn với khoảng này",
"compareSameAsPrev": "giống kỳ trước",
"compareNoLineAbout": "Về đường so sánh bị thiếu",
"compareNoLineBreakdown": "Biểu đồ không có đường so sánh khi đang bật phân tách: mỗi giá trị của {field} là một đường riêng. Chỉ số chính vẫn so sánh toàn bộ phễu với {range}.",
"thisPeriod": "Kỳ này",
"changeHeader": "Thay đổi (điểm %)",
"changeHeaderTooltip": "Thay đổi theo điểm phần trăm, trong chuyển đổi từ bước trên.",
"changePts": "{arrow}{value} điểm",
"lowVolume": "≈ khối lượng thấp",
"changeTooltip": "Khoảng tin cậy 95%: ± {ci} điểm. Trên {cur} {unit} kỳ này và {prev} kỳ trước.",
"previousFailed": "Không thể tải kỳ trước",
"previousCount": "so với {count}",
"viewSteps": "Các bước",
"viewTrend": "Xu hướng",
"trendPairOption": "Bước {a} → Bước {b}",
"trendFrom": "Chuyển đổi từ",
"trendTo": "đến",
"trendLastStep": "{step} (bước cuối)",
"trendFromHere": "Hiển thị xu hướng chuyển đổi từ bước này",
"trendInterval": "Khoảng thời gian",
"intervalAuto": "Tự động",
"intervalAutoResolved": "Tự động ({interval})",
"intervalDay": "Ngày",
"intervalWeek": "Tuần",
"intervalMonth": "Tháng",
"intervalNameDay": "ngày",
"intervalNameWeek": "tuần",
"intervalNameMonth": "tháng",
"trendAxis": "Chuyển đổi (%)",
"trendNoUnits": "Không có {unit}",
"trendNoDataBefore": "Không có dữ liệu trước {date}",
"trendArriving": "Dữ liệu vẫn đang đến",
"trendStillConverting": "Vẫn đang chuyển đổi",
"trendStillConvertingToday": "Vẫn đang chuyển đổi (hôm nay)",
"trendStillConvertingTooltip": "Một số {unit} này vẫn có thể đạt bước {step}, nên tỷ lệ này có thể tăng.",
"trendLowVolume": "Lưu lượng thấp",
"trendLowVolumeTooltip": "Ít hơn {n} {unit} đã vào, nên chỉ cần một đơn vị thay đổi là tỷ lệ này dao động mạnh.",
"trendPartial": "Một phần",
"trendPartialTooltip": "Khoảng này chỉ bao phủ một phần của {interval}.",
"trendCaption": "{range} · {tz}",
"trendEntered": "Đã vào bước {n}",
"trendReached": "Đã đạt bước {n}",
"trendPrevSameDay": "Kỳ trước, cùng ngày",
"trendPrevSameWeek": "Kỳ trước, cùng tuần",
"trendPrevSameMonth": "Kỳ trước, cùng tháng",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · không có {unit}",
"trendPrevSeriesOf": "{range} ({label})",
"trendEmpty": "Không có {unit} trong khoảng này",
"trendEmptyDescription": "Không ai đạt bước {step} trong khoảng ngày này. Hãy thử khoảng rộng hơn.",
"trendTooShort": "Chọn khoảng ít nhất 2 ngày để xem tỷ lệ chuyển đổi theo thời gian",
"trendTooMany": "Chọn khoảng ngắn hơn hoặc khoảng chia thời gian dài hơn để xem tỷ lệ chuyển đổi theo thời gian",
"trendAverage": "Tỷ lệ chuyển đổi trung bình",
"trendAverageTooltip": "Tính tổng trên mọi {interval} đã kết thúc chuyển đổi; những khoảng vẫn đang chuyển đổi sẽ không được tính.",
"trendTooltipRate": "{rate} ({reached} trong {entered})",
"trendTooltipPrev": "Kỳ trước, {date}: {rate}",
"alertTrend": "Chuyển sang Bước để thiết lập cảnh báo",
"overallVs": "so với {pct} trong {range}",
"overallVsAgo": "so với {pct} {label} ({range})",
"compareEmpty": "Không có {unit} trong {range} để so sánh",
"breakdownPrevTooltip": "{last} trên {first} {unit} trong {range}",
"nextStep": "Bước tiếp theo, phổ biến nhất sau bước {step}:",
"searchStep": "Tìm kiếm trang và lượt nhấp",
"searchStepAll": "Tìm kiếm trang, lượt nhấp, sự kiện và lỗi",
@@ -2261,6 +2333,9 @@
"atLeast": "ít nhất",
"notSet": "(chưa đặt)",
"other": "Khác",
"leftBefore": "Rời đi trước bước {step}",
"reachedNoValue": "Đã đạt bước {step}, không có giá trị",
"breakdownFieldMissing": "{field} không phải là trường trong dữ liệu của ứng dụng này, nên tất cả {unit} đều được hiển thị là chưa đặt.",
"total": "Tổng",
"conversion": "Chuyển đổi",
"usersFallback": "Liên kết này đếm người dùng, nhưng không có danh tính người dùng nào được phân giải cho ứng dụng và khoảng này, nên nó đếm phiên.",
@@ -2273,17 +2348,20 @@
"pickFirst": "Hoặc chọn bước 1 từ trang và lượt nhấp",
"overall": "Chuyển đổi tổng thể",
"overallOf": "{last} trên {first} {unit}",
"overallTime": "· trung vị {median} để chuyển đổi (p90 {p90})",
"countBy": "Đếm theo",
"sessions": "Phiên",
"sessionNoun": "phiên",
"windowSession": "Cùng phiên",
"window1h": "Trong vòng 1 giờ",
"window1d": "Trong vòng 1 ngày",
"window7d": "Trong vòng 7 ngày",
"noBreakdown": "Không phân tách",
"breakdownLabel": "Phân tách",
"breakdownNone": "Không",
"windowSession": "cùng phiên · một lượt truy cập",
"windowSessionShort": "cùng phiên",
"window1h": "1 giờ",
"window1d": "1 ngày",
"window7d": "7 ngày",
"windowOfStep1": "{window} · tính từ bước 1",
"whoFinishIn": "những người hoàn thành các bước trong",
"viewAs": "Xem dưới dạng",
"headlineWithin": "trong vòng {window} kể từ bước 1",
"headlineMedian": "thời gian chuyển đổi trung vị là {value}",
"headlineEstimated": "ước tính.",
"clearSteps": "Xóa các bước",
"dims": {
"browser": "Trình duyệt",
@@ -2303,7 +2381,6 @@
"usersExact": "Chế độ Người dùng chạy chính xác và có thể chậm trong khoảng thời gian này.",
"step1EmptyTitle": "Không có {unit} nào đạt đến bước 1 trong khoảng thời gian này",
"step1EmptyDescription": "Không ai đạt đến {step} trong khoảng thời gian này. Hãy mở rộng khoảng thời gian, hoặc bắt đầu từ một trang hàng đầu.",
"breakdownTimeHidden": "Thời gian chuyển đổi được hiển thị cho phễu không có phân tách.",
"alertMe": "Cảnh báo cho tôi khi tỷ lệ chuyển đổi giảm xuống dưới mức hôm nay",
"alertNeedsRun": "Tạo phễu từ hai bước trở lên để đặt cảnh báo cho nó",
"alertWaiting": "Chờ phễu đếm xong",
@@ -2371,7 +2448,64 @@
"filterRejected": "Không thể dùng các giá trị này làm bộ lọc. Mỗi giá trị dài từ 1 đến 1024 ký tự, tối đa 20 giá trị.",
"whyEstimated": "Ước tính: các dòng này có thể không cộng lại đúng bằng số lượng đầu vào.",
"tooLongForLink": "Bước này khiến phễu quá dài để giữ trong liên kết; hãy rút ngắn hoặc xóa bớt một bước trước",
"editTooLongForLink": "Thay đổi này khiến phễu quá dài để giữ trong liên kết; hãy xóa một bước, bộ lọc hoặc loại trừ trước"
"editTooLongForLink": "Thay đổi này khiến phễu quá dài để giữ trong liên kết; hãy xóa một bước, bộ lọc hoặc loại trừ trước",
"breakdownRemove": "Xóa phân tách",
"breakdownAdd": "Thêm phân tách",
"breakdownCaption": "Tách từng bước theo một trường, chẳng hạn như trình duyệt hoặc phương thức thanh toán",
"breakdownOneAtATime": "Mỗi lần một phân tách",
"breakdownReplace": "Thay phân tách bằng {field}",
"breakdownValueFrom": "Giá trị từ",
"pickerOnSteps": "Trên các bước này",
"pickerKeyFields": "Trường chính",
"pickerUser": "Người dùng",
"pickerAllFields": "Tất cả trường",
"pickerStepHint": "Bước {step} · {values}",
"pickerStepHintMore": "Bước {step} · {values} +{more}",
"pickerManyValues": "Bước {step} · nhiều giá trị",
"pickerUserReason": "Các trường người dùng chỉ được dùng cho bộ lọc",
"pickerSampling": "Đang đọc các trường trên các bước của phễu này…",
"pickerSampleFailed": "Không thể đọc các trường trên các bước của phễu này",
"fromStep": "Từ bước {step}",
"fromAnyStep": "Từ bất kỳ bước nào · {pct} được đặt",
"fromAutomatic": "Tự động (bước {step})",
"fromAutomaticNone": "Tự động",
"fromStepItem": "Bước {step} · {name}",
"stepMenu": "Thao tác bước",
"breakDownByStep": "Phân tích theo một thuộc tính của bước này",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "Nhiều giá trị",
"breakdownBy": "Theo {field}",
"headlineBreakdown": "theo {field}",
"breakdownAbout": "Về phân tích này",
"breakdownAboutAuto": "{field} được đặt lần đầu tại bước {step}, {name}, ở {covered} trong số {reached} {unit} đã đến bước đó, nên tỷ lệ của nó bắt đầu từ bước {step}.",
"breakdownAboutPinned": "{field} được đọc tại bước {step}, {name}, bước được chọn cho trường này; {covered} trong số {reached} {unit} đã đến bước đó có giá trị, nên tỷ lệ của nó bắt đầu từ bước {step}.",
"breakdownAboutAny": "{field} được đặt trên {covered} trong số {reached} {unit}, và với một nửa số đối tượng đến được nó thì trường này không được đặt ở bước nào, nên tỷ lệ của nó bắt đầu từ bước 1.",
"breakdownAboutLeft": "Những đối tượng rời đi trước bước {step} không có giá trị và được tính trong một hàng.",
"conversionFromStep": "Chuyển đổi từ bước {step}",
"medianTime": "Thời gian trung vị",
"lowVolumeTag": "Lưu lượng thấp",
"lowVolumeNote": "Các giá trị dưới {count} {unit} tại bước {step} được đánh dấu là lưu lượng thấp",
"propertyTitle": "Thuộc tính",
"propertyField": "Trường",
"propertyAddValue": "Giá trị",
"propertyAdd": "Thêm thuộc tính",
"propertyRemove": "Xóa thuộc tính",
"propertyInvalid": "Mẫu không hợp lệ",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "Không thấy trường nào trên bước này",
"propertyFieldsLoading": "Đang đọc các trường của bước này…",
"propertyFieldsFailed": "Không thể đọc các trường của bước này",
"propConditions": {
"in": "là",
"notIn": "không phải là",
"contains": "chứa",
"regex": "khớp"
},
"filterNotice": "Giữ lại {kept} trong số {opened} {unit}.",
"filterNoticeAction": "Phân tích theo {field}",
"filterNoticeWhy": "Tại sao mọi bước đều hiển thị 100%",
"filterNoticeReason": "Lọc tất cả các bước sẽ giữ hoặc loại bỏ toàn bộ {unit}. {missing} trong số {opened} {unit} đã vào bước 1 chưa từng đặt {field}, nên chúng bị loại trước bước 1 và các bước đầu có thể hiển thị 100%.",
"pickerOnStep": "Trên bước {step}"
},
"viewer": {
"droppedAfter": "Rời bỏ sau bước {step}: {label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "Phễu: {first} → {last}",
"funnelTrendTitle": "Chuyển đổi theo thời gian: {first} → {last}",
"step": "Bước",
"add": "Thêm vào bảng điều khiển",
"funnelOnly": "Bảng điều khiển hỗ trợ phễu Sessions không có phân tách",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "会话若在这两个步骤之间执行了此操作,则在第二个步骤处停止计数。",
"addExclusionStep": "添加排除步骤",
"railSteps": "步骤",
"railSettings": "设置",
"collapseRail": "收起侧边面板",
"expandRail": "展开侧边面板",
"addStep": "添加步骤",
"railWindow": "窗口",
"railBreakdown": "细分",
"where": "其中",
"alert": "告警",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "该面板会按当前状态保留这些步骤、过滤器和命名事件规则,适用于整个应用,不包含本页面的环境、版本或对比应用。使用该应用当前的应用过滤器;它们变更后请重新创建。",
"needsNewer": "此漏斗需要更新的版本",
"step": "步骤",
"ofFirst": "占步骤 1",
"ofPrevious": "相较于上一步",
"ofPreviousTooltip": "到达此步骤的占比(相对于上一步)",
"ofFirst": "从第 1 步开始的转化",
"ofPrevious": "从上一步开始的转化",
"ofPreviousTooltip": "上一步中到达此步骤的占比",
"ofFirstTooltip": "到达此步骤的占比(相对于第 1 步)。阴影部分:被排除步骤移除的部分",
"ofFirstCompareTooltip": "第 1 步中到达此步骤的占比。实心柱:本期。斜纹柱:上期。",
"unitTooltip": "到达此步骤的 {unit}",
"timeFromFirst": "距步骤 1 的时间",
"timeTooltip": "从步骤 1 事件按顺序到此步骤的最短时间,以{unit}为单位",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "查看流失原因",
"droppedAria": "在步骤 {step} 后 {count} 流失({pct}),在侧边面板中查看原因",
"noneDropped": "0 流失",
"droppedCompare": "{pct} 流失({count}),较上期 {prevPct}({prevCount})",
"droppedCompareAria": "{pct} 流失({count}),较上期 {prevPct}({prevCount}),第 {step} 步之后,可在侧边面板查看原因",
"droppedCompareTooltip": "本期,{range}:{outOf} {unit} 中流失 {pct}。上期,{prevRange}:{prevOutOf} {unit} 中流失 {prevPct}。打开本期的流失详情。",
"compareTooLong": "对比仅支持最长 31 天的范围。请选择更短的范围进行对比。",
"compareTo": "对比对象",
"compareOff": "关闭",
"comparePrev": "上期",
"compareAgo1h": "1 小时前",
"compareAgo1d": "1 天前",
"compareAgo1w": "1 周前",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "与本范围重叠",
"compareSameAsPrev": "与上期相同",
"compareNoLineAbout": "关于缺失的对比线",
"compareNoLineBreakdown": "开启拆分时,图表不会显示对比线:{field} 的每个值都会单独成线。汇总指标仍会将整个漏斗与 {range} 进行对比。",
"thisPeriod": "本期",
"changeHeader": "变化(百分点)",
"changeHeaderTooltip": "较上一步转化率的百分点变化。",
"changePts": "{arrow}{value} 个百分点",
"lowVolume": "≈ 数据量低",
"changeTooltip": "95% 区间:± {ci} 个百分点。本期共 {cur} {unit},上期共 {prev}。",
"previousFailed": "上期数据加载失败",
"previousCount": "对比 {count}",
"viewSteps": "步骤",
"viewTrend": "趋势",
"trendPairOption": "第 {a} 步 → 第 {b} 步",
"trendFrom": "转化:从",
"trendTo": "至",
"trendLastStep": "{step}(最后一步)",
"trendFromHere": "显示从该步骤起的转化趋势",
"trendInterval": "时间间隔",
"intervalAuto": "自动",
"intervalAutoResolved": "自动({interval})",
"intervalDay": "天",
"intervalWeek": "周",
"intervalMonth": "月",
"intervalNameDay": "天",
"intervalNameWeek": "周",
"intervalNameMonth": "月",
"trendAxis": "转化率(%)",
"trendNoUnits": "无 {unit}",
"trendNoDataBefore": "{date} 之前无数据",
"trendArriving": "数据仍在传入",
"trendStillConverting": "仍在转化",
"trendStillConvertingToday": "仍在转化(今日)",
"trendStillConvertingTooltip": "其中一些 {unit} 仍可能到达步骤 {step},因此该转化率可能还会上升。",
"trendLowVolume": "数据量低",
"trendLowVolumeTooltip": "Fewer than {n} {unit} entered, so this rate moves a lot with each one.",
"trendPartial": "部分",
"trendPartialTooltip": "该范围仅覆盖此 {interval} 的一部分。",
"trendCaption": "{range} · {tz}",
"trendEntered": "进入步骤 {n}",
"trendReached": "到达步骤 {n}",
"trendPrevSameDay": "上一周期,同一天",
"trendPrevSameWeek": "上一周期,同一周",
"trendPrevSameMonth": "上一周期,同一月",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · 无 {unit}",
"trendPrevSeriesOf": "{range}({label})",
"trendEmpty": "此范围内没有 {unit}",
"trendEmptyDescription": "这些日期之间没有人到达步骤 {step}。请尝试扩大范围。",
"trendTooShort": "请选择至少 2 天的范围以查看随时间变化的转化情况",
"trendTooMany": "请选择更短的范围或更长的间隔以查看随时间变化的转化情况",
"trendAverage": "平均转化率",
"trendAverageTooltip": "Summed over every {interval} whose conversion has closed; still-converting ones are left out.",
"trendTooltipRate": "{rate}({reached} / {entered})",
"trendTooltipPrev": "上一周期,{date}:{rate}",
"alertTrend": "切换到“步骤”以设置告警",
"overallVs": "相比 {pct}({range})",
"overallVsAgo": "相比 {pct} {label}({range})",
"compareEmpty": "没有 {unit} 可在 {range} 中用于比较",
"breakdownPrevTooltip": "{last}(共 {first} {unit})在 {range} 中",
"nextStep": "下一步,步骤 {step} 后最常见:",
"searchStep": "搜索页面和点击",
"searchStepAll": "搜索页面、点击、事件和错误",
@@ -2261,6 +2333,9 @@
"atLeast": "至少",
"notSet": "(未设置)",
"other": "其他",
"leftBefore": "在步骤 {step} 之前离开",
"reachedNoValue": "到达步骤 {step},但无值",
"breakdownFieldMissing": "{field} 不是此应用数据中的字段,因此所有 {unit} 都会显示为未设置。",
"total": "总计",
"conversion": "转化率",
"usersFallback": "此链接按用户计数,但此应用和范围内无法解析用户身份,因此按会话计数。",
@@ -2273,17 +2348,20 @@
"pickFirst": "或者从页面和点击中选择步骤 1",
"overall": "总体转化率",
"overallOf": "{first} {unit} 中的 {last}",
"overallTime": "· 转化中位时间 {median}(p90 {p90})",
"countBy": "计数依据",
"sessions": "会话",
"sessionNoun": "会话",
"windowSession": "同一会话",
"window1h": "1 小时内",
"window1d": "1 天内",
"window7d": "7 天内",
"noBreakdown": "无细分",
"breakdownLabel": "细分",
"breakdownNone": "无",
"windowSession": "同一会话 · 一次访问",
"windowSessionShort": "同一会话",
"window1h": "1 小时",
"window1d": "1 天",
"window7d": "7 天",
"windowOfStep1": "{window} · 自步骤 1 起",
"whoFinishIn": "在以下时间内完成步骤的用户",
"viewAs": "查看方式",
"headlineWithin": "在步骤 1 后的 {window} 内",
"headlineMedian": "转化所需时间中位数为 {value}",
"headlineEstimated": "估算",
"clearSteps": "清除步骤",
"dims": {
"browser": "浏览器",
@@ -2303,7 +2381,6 @@
"usersExact": "用户模式为精确计算,在此时间范围内可能较慢。",
"step1EmptyTitle": "此范围内没有 {unit} 到达第 1 步",
"step1EmptyDescription": "此范围内没有用户到达 {step}。可扩大范围,或改为从热门页面开始。",
"breakdownTimeHidden": "转化时间仅在未做拆分的漏斗中显示。",
"alertMe": "当转化率低于今天的值时告警",
"alertNeedsRun": "构建包含两个或更多步骤的漏斗才能对其告警",
"alertWaiting": "请等待漏斗完成计数",
@@ -2371,7 +2448,64 @@
"filterRejected": "这些值不能用作过滤器。每个值长度为 1 到 1024 个字符,最多 20 个。",
"whyEstimated": "估算值:这些行的总和可能与进入数不完全一致。",
"tooLongForLink": "此步骤会使漏斗过长,无法保留在链接中;请先缩短或移除某个步骤",
"editTooLongForLink": "此更改会使漏斗过长,无法保留在链接中;请先移除一个步骤、过滤器或排除项"
"editTooLongForLink": "此更改会使漏斗过长,无法保留在链接中;请先移除一个步骤、过滤器或排除项",
"breakdownRemove": "移除细分",
"breakdownAdd": "添加细分",
"breakdownCaption": "按一个字段拆分每个步骤,例如浏览器或支付方式",
"breakdownOneAtATime": "一次只能使用一个细分",
"breakdownReplace": "将细分替换为 {field}",
"breakdownValueFrom": "值来源",
"pickerOnSteps": "在这些步骤上",
"pickerKeyFields": "关键字段",
"pickerUser": "用户",
"pickerAllFields": "所有字段",
"pickerStepHint": "步骤 {step} · {values}",
"pickerStepHintMore": "步骤 {step} · {values} +{more}",
"pickerManyValues": "步骤 {step} · 多个值",
"pickerUserReason": "用户字段仅供过滤器使用",
"pickerSampling": "正在读取此漏斗各步骤的字段…",
"pickerSampleFailed": "无法读取此漏斗各步骤的字段",
"fromStep": "从步骤 {step} 开始",
"fromAnyStep": "从任意步骤 · {pct} 已设置",
"fromAutomatic": "自动(步骤 {step})",
"fromAutomaticNone": "自动",
"fromStepItem": "步骤 {step} · {name}",
"stepMenu": "步骤操作",
"breakDownByStep": "按此步骤的某个属性细分",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "多个值",
"breakdownBy": "按 {field}",
"headlineBreakdown": "按 {field}",
"breakdownAbout": "关于此细分",
"breakdownAboutAuto": "在到达步骤 {step}({name})的 {reached} 个{unit}中,有 {covered} 个首次设置 {field},因此其比率从步骤 {step} 开始计算。",
"breakdownAboutPinned": "在为其选定的步骤 {step}({name})读取 {field};到达该步骤的 {reached} 个{unit}中有 {covered} 个带有值,因此其比率从步骤 {step} 开始计算。",
"breakdownAboutAny": "{field} is set on {covered} of the {reached} {unit}, and on no step for half of those reaching it, so its rates start at step 1.",
"breakdownAboutLeft": "在步骤 {step} 之前离开的没有值,会计入同一行。",
"conversionFromStep": "从步骤 {step} 起的转化",
"medianTime": "中位时间",
"lowVolumeTag": "低数据量",
"lowVolumeNote": "步骤 {step} 中低于 {count} {unit} 的值会标记为低数据量",
"propertyTitle": "属性",
"propertyField": "字段",
"propertyAddValue": "值",
"propertyAdd": "添加属性",
"propertyRemove": "移除属性",
"propertyInvalid": "模式无效",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "此步骤未发现字段",
"propertyFieldsLoading": "正在读取此步骤的字段…",
"propertyFieldsFailed": "无法读取此步骤的字段",
"propConditions": {
"in": "等于",
"notIn": "不等于",
"contains": "包含",
"regex": "匹配"
},
"filterNotice": "在 {opened} 个{unit}中保留 {kept} 个。",
"filterNoticeAction": "按 {field} 细分",
"filterNoticeWhy": "为什么每个步骤都显示 100%",
"filterNoticeReason": "筛选所有步骤会整体保留或丢弃{unit}。打开步骤 1 的 {opened} 个{unit}中有 {missing} 个从未设置 {field},因此它们会在步骤 1 之前被丢弃,早期步骤便可能显示 100%。",
"pickerOnStep": "在步骤 {step} 上"
},
"viewer": {
"droppedAfter": "在第 {step} 步之后流失:{label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "漏斗:{first} → {last}",
"funnelTrendTitle": "转化率随时间变化:{first} → {last}",
"step": "步骤",
"add": "添加到仪表盘",
"funnelOnly": "仪表盘支持无细分的会话漏斗",
+150 -15
View File
@@ -2204,11 +2204,9 @@
"exclusionStepsCaption": "若工作階段在兩個步驟之間執行了此動作,則會在第二個步驟停止計數。",
"addExclusionStep": "新增排除步驟",
"railSteps": "步驟",
"railSettings": "管理",
"collapseRail": "收合側邊面板",
"expandRail": "展開側邊面板",
"addStep": "新增步驟",
"railWindow": "時間視窗",
"railBreakdown": "細分",
"where": "其中",
"alert": "警示",
@@ -2233,10 +2231,11 @@
"dashboardSnapshot": "面板會將這些步驟、篩選條件與具名事件規則保留為目前的狀態,並套用於整個應用程式,但不包含此頁面的環境、版本或比較的應用程式。使用此應用程式目前的應用程式篩選條件;變更後請重新建立。",
"needsNewer": "此漏斗需要較新版本",
"step": "步驟",
"ofFirst": "佔步驟 1",
"ofPrevious": "來自上一步",
"ofPreviousTooltip": "上一步中到達此步驟的比例",
"ofFirst": "自步驟 1 的轉換率",
"ofPrevious": "自上一步驟的轉換率",
"ofPreviousTooltip": "上一步驟中到達此步驟的比例",
"ofFirstTooltip": "第 1 步中到達此步驟的比例。斜線部分:被排除步驟移除",
"ofFirstCompareTooltip": "步驟 1 中到達此步驟的比例。實心長條:本期。斜線長條:上一期間。",
"unitTooltip": "到達此步驟的 {unit}",
"timeFromFirst": "距步驟 1 的時間",
"timeTooltip": "從步驟 1 事件到此步驟的最短依序時間(依 {unit})",
@@ -2251,6 +2250,79 @@
"droppedTooltip": "查看流失原因",
"droppedAria": "步驟 {step} 之後有 {count} 個流失({pct}),可在側面板查看原因",
"noneDropped": "0 個流失",
"droppedCompare": "{pct} 流失({count}),對比上一期間 {prevPct}({prevCount})",
"droppedCompareAria": "{pct} 流失({count}),對比上一期間 {prevPct}({prevCount}),在步驟 {step} 之後,於側邊面板查看原因",
"droppedCompareTooltip": "本期,{range}:{pct}(共 {outOf} {unit})。上一期間,{prevRange}:{prevPct}(共 {prevOutOf} {unit})。開啟本期的流失分析。",
"compareTooLong": "比較僅支援最長 31 天的範圍。請選擇較短的範圍進行比較。",
"compareTo": "比較對象",
"compareOff": "關閉",
"comparePrev": "上一期間",
"compareAgo1h": "1 小時前",
"compareAgo1d": "1 天前",
"compareAgo1w": "1 週前",
"compareOption": "{label} · {range}",
"compareOptionDisabled": "{label} · {reason}",
"compareOverlaps": "與此範圍重疊",
"compareSameAsPrev": "與上一期間相同",
"compareNoLineAbout": "關於缺少的比較線",
"compareNoLineBreakdown": "啟用細分時,圖表不會顯示比較線:{field} 的每個值各自成一條線。主要數字仍會將整個漏斗與 {range} 比較。",
"thisPeriod": "本期",
"changeHeader": "變化(百分點)",
"changeHeaderTooltip": "轉換率相對上一步驟的變化,以百分點計。",
"changePts": "{arrow}{value} 百分點",
"lowVolume": "≈ 資料量偏低",
"changeTooltip": "95% 區間:± {ci} 百分點。本期共 {cur} {unit},上一期間共 {prev}。",
"previousFailed": "無法載入上一期間",
"previousCount": "對比 {count}",
"viewSteps": "步驟",
"viewTrend": "趨勢",
"trendPairOption": "步驟 {a} → 步驟 {b}",
"trendFrom": "轉換自",
"trendTo": "至",
"trendLastStep": "{step}(最後一步)",
"trendFromHere": "顯示從此步驟開始的轉換趨勢",
"trendInterval": "間隔",
"intervalAuto": "自動",
"intervalAutoResolved": "自動({interval})",
"intervalDay": "日",
"intervalWeek": "週",
"intervalMonth": "月",
"intervalNameDay": "日",
"intervalNameWeek": "週",
"intervalNameMonth": "月",
"trendAxis": "轉換率(%)",
"trendNoUnits": "無 {unit}",
"trendNoDataBefore": "{date} 之前沒有資料",
"trendArriving": "資料仍在傳入",
"trendStillConverting": "仍在轉換",
"trendStillConvertingToday": "仍在轉換(今日)",
"trendStillConvertingTooltip": "其中一些 {unit} 仍可能到達步驟 {step},因此此比率可能上升。",
"trendLowVolume": "量少",
"trendLowVolumeTooltip": "進入的 {unit} 少於 {n},因此每增減一個都會讓此比率大幅變動。",
"trendPartial": "部分",
"trendPartialTooltip": "此範圍只涵蓋這個 {interval} 的一部分。",
"trendCaption": "{range} · {tz}",
"trendEntered": "進入步驟 {n}",
"trendReached": "到達步驟 {n}",
"trendPrevSameDay": "上一期間的同一天",
"trendPrevSameWeek": "上一期間的同一週",
"trendPrevSameMonth": "上一期間的同一月",
"trendPrevCell": "{date} · {rate}",
"trendPrevEmpty": "{date} · 無 {unit}",
"trendPrevSeriesOf": "{range}({label})",
"trendEmpty": "此範圍內沒有 {unit}",
"trendEmptyDescription": "這些日期之間沒有人到達步驟 {step}。請嘗試更大的範圍。",
"trendTooShort": "請選擇至少 2 天的範圍,以查看轉換隨時間的變化",
"trendTooMany": "請選擇較短的範圍或較長的間隔,以查看轉換隨時間的變化",
"trendAverage": "平均轉換率",
"trendAverageTooltip": "彙總所有轉換已結束的 {interval};仍在轉換中的則不列入。",
"trendTooltipRate": "{rate}({reached}/{entered})",
"trendTooltipPrev": "上一期間,{date}:{rate}",
"alertTrend": "切換至「步驟」以設定警示",
"overallVs": "相較於 {range} 的 {pct}",
"overallVsAgo": "相較於 {label} 的 {pct}({range})",
"compareEmpty": "{range} 中沒有 {unit} 可供比較",
"breakdownPrevTooltip": "{range} 內 {first} 個 {unit} 中的 {last} 個",
"nextStep": "下一步,步驟 {step} 之後最常見:",
"searchStep": "搜尋頁面與點擊",
"searchStepAll": "搜尋頁面、點擊、事件和錯誤",
@@ -2261,6 +2333,9 @@
"atLeast": "至少",
"notSet": "(未設定)",
"other": "其他",
"leftBefore": "在步驟 {step} 前離開",
"reachedNoValue": "到達步驟 {step},但無值",
"breakdownFieldMissing": "{field} 不是此應用程式資料中的欄位,因此所有 {unit} 都顯示為未設定。",
"total": "總計",
"conversion": "轉換率",
"usersFallback": "此連結以使用者計數,但此應用程式與所選範圍內無法解析使用者身分,因此改以工作階段計數。",
@@ -2273,17 +2348,20 @@
"pickFirst": "或從頁面與點擊中選擇步驟 1",
"overall": "整體轉換率",
"overallOf": "{first} {unit} 中的 {last}",
"overallTime": "· 中位數 {median} 完成轉換(p90 {p90})",
"countBy": "計數依據",
"sessions": "工作階段",
"sessionNoun": "工作階段",
"windowSession": "同一工作階段",
"window1h": "1 小時內",
"window1d": "1 天內",
"window7d": "7 天內",
"noBreakdown": "無細分",
"breakdownLabel": "細分",
"breakdownNone": "無",
"windowSession": "同一工作階段 · 一次造訪",
"windowSessionShort": "同一工作階段",
"window1h": "1 小時",
"window1d": "1 天",
"window7d": "7 天",
"windowOfStep1": "{window} · 自步驟 1 起",
"whoFinishIn": "完成步驟的時限",
"viewAs": "檢視方式",
"headlineWithin": "在步驟 1 之後的 {window} 內",
"headlineMedian": "轉換時間中位數 {value}",
"headlineEstimated": "估計",
"clearSteps": "清除步驟",
"dims": {
"browser": "瀏覽器",
@@ -2303,7 +2381,6 @@
"usersExact": "使用者模式會精確執行,在此範圍內可能較慢。",
"step1EmptyTitle": "此範圍內沒有 {unit} 到達步驟 1",
"step1EmptyDescription": "此範圍內沒有人到達 {step}。請擴大範圍,或改從熱門頁面開始。",
"breakdownTimeHidden": "轉換時間僅顯示於未使用分解的漏斗。",
"alertMe": "當轉換率低於今日的轉換率時警示我",
"alertNeedsRun": "建立包含兩個以上步驟的漏斗才能設定警示",
"alertWaiting": "請等待漏斗完成計數",
@@ -2371,7 +2448,64 @@
"filterRejected": "這些值無法做為篩選條件。每個值長度為 1 到 1024 個字元,最多 20 個。",
"whyEstimated": "估計值:這些數字加總可能不會正好等於進入數。",
"tooLongForLink": "此步驟會使漏斗過長而無法保留於連結中;請先縮短或移除步驟",
"editTooLongForLink": "此變更會使漏斗過長而無法保留在連結中;請先移除步驟、篩選條件或排除條件"
"editTooLongForLink": "此變更會使漏斗過長而無法保留在連結中;請先移除步驟、篩選條件或排除條件",
"breakdownRemove": "移除細分",
"breakdownAdd": "新增細分",
"breakdownCaption": "依一個欄位細分每個步驟,例如瀏覽器或付款方式",
"breakdownOneAtATime": "一次一個細分",
"breakdownReplace": "以 {field} 取代細分",
"breakdownValueFrom": "值來源",
"pickerOnSteps": "在這些步驟上",
"pickerKeyFields": "關鍵欄位",
"pickerUser": "使用者",
"pickerAllFields": "所有欄位",
"pickerStepHint": "步驟 {step} · {values}",
"pickerStepHintMore": "步驟 {step} · {values} +{more}",
"pickerManyValues": "步驟 {step} · 多個值",
"pickerUserReason": "使用者欄位僅供篩選使用",
"pickerSampling": "正在讀取此漏斗各步驟的欄位…",
"pickerSampleFailed": "無法讀取此漏斗各步驟的欄位",
"fromStep": "從步驟 {step}",
"fromAnyStep": "從任一步驟 · 已設定 {pct}",
"fromAutomatic": "自動(步驟 {step})",
"fromAutomaticNone": "自動",
"fromStepItem": "步驟 {step} · {name}",
"stepMenu": "步驟操作",
"breakDownByStep": "依此步驟的屬性細分",
"stepFieldValues": "{values} +{more}",
"stepFieldMany": "多個值",
"breakdownBy": "依 {field}",
"headlineBreakdown": "依 {field}",
"breakdownAbout": "關於此細分",
"breakdownAboutAuto": "{field} 首次設定於步驟 {step}({name});在到達該步驟的 {reached} 個 {unit} 中,有 {covered} 個已設定,因此其比率從步驟 {step} 開始。",
"breakdownAboutPinned": "{field} 在為其選定的步驟 {step}({name})讀取;到達該步驟的 {reached} 個 {unit} 中,有 {covered} 個帶有值,因此其比率從步驟 {step} 開始。",
"breakdownAboutAny": "{field} is set on {covered} of the {reached} {unit}, and on no step for half of those reaching it, so its rates start at step 1.",
"breakdownAboutLeft": "在步驟 {step} 前離開者沒有值,會計在同一列。",
"conversionFromStep": "從步驟 {step} 開始的轉換率",
"medianTime": "中位數時間",
"lowVolumeTag": "低量",
"lowVolumeNote": "步驟 {step} 中低於 {count} {unit} 的值會標為低量",
"propertyTitle": "屬性",
"propertyField": "欄位",
"propertyAddValue": "值",
"propertyAdd": "新增屬性",
"propertyRemove": "移除屬性",
"propertyInvalid": "不是有效的模式",
"propertyValueCount": "{value} · {count}",
"propertyNoFields": "此步驟未看到任何欄位",
"propertyFieldsLoading": "正在讀取此步驟的欄位…",
"propertyFieldsFailed": "無法讀取此步驟的欄位",
"propConditions": {
"in": "是",
"notIn": "不是",
"contains": "包含",
"regex": "符合"
},
"filterNotice": "保留 {opened} 個 {unit} 中的 {kept} 個。",
"filterNoticeAction": "依 {field} 細分",
"filterNoticeWhy": "為什麼每個步驟都顯示 100%",
"filterNoticeReason": "篩選所有步驟會以整個 {unit} 為單位保留或捨棄。開啟步驟 1 的 {opened} 個 {unit} 中,有 {missing} 個從未設定 {field},因此它們會在步驟 1 之前被捨棄,前期步驟便可能顯示 100%。",
"pickerOnStep": "在步驟 {step}"
},
"viewer": {
"droppedAfter": "在步驟 {step} 後流失:{label}",
@@ -2777,6 +2911,7 @@
},
"dashboard": {
"funnelTitle": "漏斗:{first} → {last}",
"funnelTrendTitle": "轉換率隨時間變化:{first} → {last}",
"step": "步驟",
"add": "新增至儀表板",
"funnelOnly": "儀表板僅支援不含細分的工作階段漏斗",
+9
View File
@@ -63,6 +63,15 @@
);
}
/* Previous-period bar in a compared funnel. Consumer: components/rum/productAnalytics/funnelParts/FunnelStepBar.vue. */
@utility bg-hatch-period {
background-image: repeating-linear-gradient(
135deg,
var(--color-progress-bar-default) 0 0.125rem,
color-mix(in srgb, var(--color-progress-bar-default) 22%, transparent) 0.125rem 0.3125rem
);
}
/* ── Drag-and-drop row affordances ──────────────────────────────────────────
Sortable adds these classes to a row CLONE it builds at runtime, so no
template can reach it and the name must be a single token. `!important`
@@ -0,0 +1,64 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`attribute breakdown SQL > carries coverage through a windowed Users run 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, first_value(NULLIF(usr_email, '') ORDER BY CASE WHEN NULLIF(usr_email, '') IS NULL THEN 1 ELSE 0 END, date) AS u0, MIN(CASE WHEN NULLIF(usr_email, '') IS NOT NULL THEN date END) AS ut, MIN(NULLIF(CAST("context_payment_method" AS VARCHAR), '')) AS dim, MIN(view_name) AS vn FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed') OR NULLIF(usr_email, '') IS NOT NULL) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, u0, ut, dim FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE)) OR u0 IS NOT NULL),
x1 AS (SELECT sid, t, ty, k, dim, CASE WHEN ty = 'action' AND k = 'product_viewed' THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, FIRST_VALUE(u0) OVER (PARTITION BY sid ORDER BY CASE WHEN u0 IS NULL THEN 1 ELSE 0 END, ut) AS uid FROM x0),
x2 AS (SELECT sid, t, ty, k, dim, m1, m2, m3, uid FROM x1 WHERE m1 = 1 OR m2 = 1 OR m3 = 1),
su AS (SELECT CASE WHEN uid IS NULL THEN 1 ELSE 0 END AS anon, sequence_step_times(86400000, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3, COUNT(DISTINCT CASE WHEN m1 = 1 THEN sid END) AS s1s, first_value(CASE WHEN m2 = 1 THEN dim END ORDER BY CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 0 ELSE 1 END, t) AS dim, MAX(CASE WHEN m1 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h1, MAX(CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h2, MAX(CASE WHEN m3 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h3 FROM x2 GROUP BY COALESCE(uid, 's:' || sid), CASE WHEN uid IS NULL THEN 1 ELSE 0 END),
pu AS (SELECT anon, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3, s1s, dim, h1, h2, h3 FROM su)
SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(CASE WHEN r1 = 1 THEN h1 ELSE 0 END) AS dc1, SUM(CASE WHEN r2 = 1 THEN h2 ELSE 0 END) AS dc2, SUM(CASE WHEN r3 = 1 THEN h3 ELSE 0 END) AS dc3, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM pu WHERE anon = 0 GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000"
`;
exports[`attribute breakdown SQL > reads a pinned step's own rows only, with that step's median 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, MIN(NULLIF(CAST("context_payment_method" AS VARCHAR), '')) AS dim, MIN(view_name) AS vn FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed')) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, dim FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE))),
x1 AS (SELECT sid, t, ty, k, dim, CASE WHEN ty = 'action' AND k = 'product_viewed' THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, CAST(NULL AS VARCHAR) AS uid FROM x0),
s AS (SELECT sid, MAX(uid) AS uid, sequence_step_times(0, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3, first_value(CASE WHEN m2 = 1 THEN dim END ORDER BY CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 0 ELSE 1 END, t) AS dim, MIN(CASE WHEN m1 = 1 THEN t END) AS t1, MAX(CASE WHEN m1 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h1, MAX(CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h2, MAX(CASE WHEN m3 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h3 FROM x1 GROUP BY sid),
ps AS (SELECT sid, uid, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3, dim, t1, h1, h2, h3 FROM s)
SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(CASE WHEN r1 = 1 THEN h1 ELSE 0 END) AS dc1, SUM(CASE WHEN r2 = 1 THEN h2 ELSE 0 END) AS dc2, SUM(CASE WHEN r3 = 1 THEN h3 ELSE 0 END) AS dc3, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM ps GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000"
`;
exports[`attribute breakdown SQL > reads the first value on any step's row, with coverage and median columns 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, MIN(NULLIF(CAST("context_payment_method" AS VARCHAR), '')) AS dim, MIN(view_name) AS vn FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed')) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, dim FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE))),
x1 AS (SELECT sid, t, ty, k, dim, CASE WHEN ty = 'action' AND k = 'product_viewed' THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, CAST(NULL AS VARCHAR) AS uid FROM x0),
s AS (SELECT sid, MAX(uid) AS uid, sequence_step_times(0, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3, first_value(CASE WHEN m1 = 1 OR m2 = 1 OR m3 = 1 THEN dim END ORDER BY CASE WHEN dim IS NOT NULL AND (m1 = 1 OR m2 = 1 OR m3 = 1) THEN 0 ELSE 1 END, t) AS dim, MIN(CASE WHEN m1 = 1 THEN t END) AS t1, MAX(CASE WHEN m1 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h1, MAX(CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h2, MAX(CASE WHEN m3 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h3 FROM x1 GROUP BY sid),
ps AS (SELECT sid, uid, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3, dim, t1, h1, h2, h3 FROM s)
SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(CASE WHEN r1 = 1 THEN h1 ELSE 0 END) AS dc1, SUM(CASE WHEN r2 = 1 THEN h2 ELSE 0 END) AS dc2, SUM(CASE WHEN r3 = 1 THEN h3 ELSE 0 END) AS dc3, approx_percentile_cont(d3, 0.5) AS mt1, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM ps GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000"
`;
exports[`attribute breakdown SQL > takes a user's first non-empty value and any session's coverage in Users mode 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, first_value(NULLIF(usr_email, '') ORDER BY CASE WHEN NULLIF(usr_email, '') IS NULL THEN 1 ELSE 0 END, date) AS u0, MIN(CASE WHEN NULLIF(usr_email, '') IS NOT NULL THEN date END) AS ut, MIN(NULLIF(CAST("context_payment_method" AS VARCHAR), '')) AS dim, MIN(view_name) AS vn FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed') OR NULLIF(usr_email, '') IS NOT NULL) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, u0, ut, dim FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE)) OR u0 IS NOT NULL),
x1 AS (SELECT sid, t, ty, k, dim, CASE WHEN ty = 'action' AND k = 'product_viewed' THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, FIRST_VALUE(u0) OVER (PARTITION BY sid ORDER BY CASE WHEN u0 IS NULL THEN 1 ELSE 0 END, ut) AS uid FROM x0),
x2 AS (SELECT sid, t, ty, k, dim, m1, m2, m3, uid FROM x1 WHERE m1 = 1 OR m2 = 1 OR m3 = 1),
s AS (SELECT sid, MAX(uid) AS uid, sequence_step_times(0, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3, first_value(CASE WHEN m1 = 1 OR m2 = 1 OR m3 = 1 THEN dim END ORDER BY CASE WHEN dim IS NOT NULL AND (m1 = 1 OR m2 = 1 OR m3 = 1) THEN 0 ELSE 1 END, t) AS dim, MIN(CASE WHEN m1 = 1 THEN t END) AS t1, MAX(CASE WHEN m1 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h1, MAX(CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h2, MAX(CASE WHEN m3 = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h3 FROM x2 GROUP BY sid),
ps AS (SELECT sid, uid, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3, dim, t1, h1, h2, h3 FROM s),
pu AS (SELECT CASE WHEN uid IS NULL THEN 1 ELSE 0 END AS anon, MAX(r1) AS r1, MAX(r2) AS r2, MAX(r3) AS r3, MIN(d2) AS d2, MIN(d3) AS d3, SUM(seen1) AS seen1, SUM(seen2) AS seen2, SUM(seen3) AS seen3, SUM(r1) AS s1s, first_value(dim ORDER BY CASE WHEN dim IS NULL THEN 1 ELSE 0 END, t1) AS dim, MAX(h1) AS h1, MAX(h2) AS h2, MAX(h3) AS h3 FROM ps GROUP BY COALESCE(uid, 's:' || sid), CASE WHEN uid IS NULL THEN 1 ELSE 0 END)
SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(CASE WHEN r1 = 1 THEN h1 ELSE 0 END) AS dc1, SUM(CASE WHEN r2 = 1 THEN h2 ELSE 0 END) AS dc2, SUM(CASE WHEN r3 = 1 THEN h3 ELSE 0 END) AS dc3, approx_percentile_cont(d3, 0.5) AS mt1, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM pu WHERE anon = 0 GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000"
`;
exports[`step property conditions (pc) SQL > flags a later step's own rows and tests the flag inside that step 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, MIN(view_name) AS vn, MAX(CASE WHEN ("context_payment_method" IS NOT NULL AND CAST("context_payment_method" AS VARCHAR) IN ('upi')) THEN 1 ELSE 0 END) AS pc2_1 FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed')) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, pc2_1 FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE))),
x1 AS (SELECT sid, t, ty, k, CASE WHEN ty = 'action' AND k = 'product_viewed' THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' AND pc2_1 = 1 THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, CAST(NULL AS VARCHAR) AS uid FROM x0),
s AS (SELECT sid, MAX(uid) AS uid, sequence_step_times(0, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3 FROM x1 GROUP BY sid),
ps AS (SELECT sid, uid, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3 FROM s)
SELECT SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(seen1) AS seen1, SUM(seen2) AS seen2, SUM(seen3) AS seen3, approx_percentile_cont(d2, 0.5) AS med2, approx_percentile_cont(d2, 0.9) AS p90_2, approx_percentile_cont(d3, 0.5) AS med3, approx_percentile_cont(d3, 0.9) AS p90_3 FROM ps LIMIT 1"
`;
exports[`step property conditions (pc) SQL > flows into the alert, the drill-ins and Why N 1`] = `
"WITH g0 AS (SELECT session_id AS sid, MAX(CASE WHEN ((type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed')) THEN 1 ELSE 0 END) AS o0, MAX(CASE WHEN ((type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed')) THEN 1 ELSE 0 END) AS k0, MAX(CASE WHEN ("context_payment_method" IS NOT NULL AND CAST("context_payment_method" AS VARCHAR) IN ('upi')) THEN 1 ELSE 0 END) AS p1 FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (((type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed'))) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) WHEN type = 'error' THEN CAST(date AS VARCHAR) ELSE type END),
g AS (SELECT sid, MAX(CASE WHEN o0 = 1 AND p1 = 1 THEN 1 ELSE 0 END) AS o1, MAX(CASE WHEN k0 = 1 AND p1 = 1 THEN 1 ELSE 0 END) AS k1 FROM g0 GROUP BY sid)
SELECT SUM(o1) AS opened, SUM(CASE WHEN k1 = 1 THEN 1 ELSE 0 END) AS kept FROM g LIMIT 1"
`;
exports[`step property conditions (pc) SQL > keeps step 1 to rows without the value when negated, and checks each rule 1`] = `
"WITH x00 AS (SELECT session_id AS sid, type AS ty, MIN(date) AS t, MIN(view_url) AS url, MIN(action_target_name) AS atn, MIN(view_name) AS vn, MAX(CASE WHEN ("context_payment_method" IS NOT NULL AND CAST("context_payment_method" AS VARCHAR) IN ('upi')) THEN 1 ELSE 0 END) AS pc1_1, MAX(CASE WHEN ("context_plan" IS NOT NULL AND strpos(CAST("context_plan" AS VARCHAR), 'pro') > 0) THEN 1 ELSE 0 END) AS pc1_2 FROM "_rumdata" WHERE application_id = 'web' AND session_id IS NOT NULL AND (type = 'view' OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'product_viewed') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'payment_info_submitted') OR (type = 'action' AND action_target_name <> '' AND regexp_replace(regexp_replace(regexp_replace(regexp_replace(action_target_name, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') = 'order_completed')) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id WHEN type = 'action' THEN CAST(date AS VARCHAR) ELSE type END),
x0 AS (SELECT sid, t, ty, CASE WHEN ty = 'view' THEN CASE WHEN url IS NULL OR url = '' THEN NULLIF(vn, '') ELSE COALESCE(NULLIF(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(regexp_replace(url, '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*', ''), '^([^?#]*)(?:\\?[^#]*)?(?:#!?(/[^?#]*))?.*$', '\\1#\\2'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '/(?:[0-9]+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}|([a-z0-9]+(?:-[a-z0-9]+){2,})|[^/#]*(?:@|%40)[^/#]*|[A-Za-z_.=%-]{0}[0-9][A-Za-z0-9_.=%-]{19,}|[A-Za-z_.=%-]{1}[0-9][A-Za-z0-9_.=%-]{18,}|[A-Za-z_.=%-]{2}[0-9][A-Za-z0-9_.=%-]{17,}|[A-Za-z_.=%-]{3}[0-9][A-Za-z0-9_.=%-]{16,}|[A-Za-z_.=%-]{4}[0-9][A-Za-z0-9_.=%-]{15,}|[A-Za-z_.=%-]{5}[0-9][A-Za-z0-9_.=%-]{14,}|[A-Za-z_.=%-]{6}[0-9][A-Za-z0-9_.=%-]{13,}|[A-Za-z_.=%-]{7}[0-9][A-Za-z0-9_.=%-]{12,}|[A-Za-z_.=%-]{8}[0-9][A-Za-z0-9_.=%-]{11,}|[A-Za-z_.=%-]{9}[0-9][A-Za-z0-9_.=%-]{10,}|[A-Za-z_.=%-]{10}[0-9][A-Za-z0-9_.=%-]{9,}|[A-Za-z_.=%-]{11}[0-9][A-Za-z0-9_.=%-]{8,}|[A-Za-z_.=%-]{12}[0-9][A-Za-z0-9_.=%-]{7,}|[A-Za-z_.=%-]{13}[0-9][A-Za-z0-9_.=%-]{6,}|[A-Za-z_.=%-]{14}[0-9][A-Za-z0-9_.=%-]{5,}|[A-Za-z_.=%-]{15}[0-9][A-Za-z0-9_.=%-]{4,}|[A-Za-z_.=%-]{16}[0-9][A-Za-z0-9_.=%-]{3,}|[A-Za-z_.=%-]{17}[0-9][A-Za-z0-9_.=%-]{2,}|[A-Za-z_.=%-]{18}[0-9][A-Za-z0-9_.=%-]{1,}|[A-Za-z_.=%-]{19}[0-9][A-Za-z0-9_.=%-]{0,})(/|#|$)', '/\\1 \\2', 'g'), '/ ', '/:id', 'g'), ' ', '', 'g'), '#/?$', ''), '(.)/+$', '\\1'), ''), '/') END WHEN ty = 'action' THEN regexp_replace(regexp_replace(regexp_replace(regexp_replace(atn, '[^\\s]*(?:@|%40)[^\\s]*', ':email', 'g'), 'eyJ[A-Za-z0-9_=-]+(?:\\.[A-Za-z0-9_=-]+)*', ':token', 'g'), '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{16,}', ':id', 'g'), '[0-9]{6,}', ':num', 'g') END AS k, pc1_1, pc1_2 FROM x00 WHERE ty = 'action' OR (ty = 'view' AND (FALSE))),
x1 AS (SELECT sid, t, ty, k, CASE WHEN ty = 'action' AND k = 'product_viewed' AND pc1_1 = 1 AND pc1_2 = 0 THEN 1 ELSE 0 END AS m1, CASE WHEN ty = 'action' AND k = 'payment_info_submitted' THEN 1 ELSE 0 END AS m2, CASE WHEN ty = 'action' AND k = 'order_completed' THEN 1 ELSE 0 END AS m3, CAST(NULL AS VARCHAR) AS uid FROM x0),
s AS (SELECT sid, MAX(uid) AS uid, sequence_step_times(0, t, m1 = 1, m2 = 1, m3 = 1) AS f, SUM(m1) AS seen1, SUM(m2) AS seen2, SUM(m3) AS seen3 FROM x1 GROUP BY sid),
ps AS (SELECT sid, uid, CASE WHEN f[1] IS NOT NULL THEN 1 ELSE 0 END AS r1, CASE WHEN f[2] IS NOT NULL THEN 1 ELSE 0 END AS r2, CASE WHEN f[3] IS NOT NULL THEN 1 ELSE 0 END AS r3, f[2] AS d2, f[3] AS d3, seen1, seen2, seen3 FROM s)
SELECT SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, SUM(seen1) AS seen1, SUM(seen2) AS seen2, SUM(seen3) AS seen3, approx_percentile_cont(d2, 0.5) AS med2, approx_percentile_cont(d2, 0.9) AS p90_2, approx_percentile_cont(d3, 0.5) AS med3, approx_percentile_cont(d3, 0.9) AS p90_3 FROM ps LIMIT 1"
`;
File diff suppressed because one or more lines are too long
+362
View File
@@ -0,0 +1,362 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
COMPARE_MAX_US,
LOW_VOLUME_MIN,
changeDirection,
compareAllowed,
compareBreakdown,
compareFunnel,
compareModeState,
compareWindow,
formatWindow,
formatWindowExact,
previousWindow,
rateChange,
} from "@/utils/rum/funnelCompare";
import { foldBreakdown, funnelResultOf, toFunnelResult } from "@/utils/rum/productAnalyticsModel";
import type { FunnelDef } from "@/utils/rum/productAnalyticsQueries";
const DAY_US = 86_400_000_000;
const HOUR_US = 3_600_000_000;
// 12 Oct 2026 00:00 UTC.
const OCT12 = Date.UTC(2026, 9, 12) * 1000;
const def = (n: number, breakdown: FunnelDef["breakdown"] = null): FunnelDef => ({
steps: Array.from({ length: n }, (_, i) => ({ kind: "c" as const, key: `s${i + 1}` })),
unit: "sessions",
window: "session",
breakdown,
});
const hit = (counts: number[]): Record<string, number> =>
Object.fromEntries(
counts.flatMap((c, i) => [
[`c${i + 1}`, c],
[`seen${i + 1}`, 1],
]),
);
const result = (counts: number[]) => toFunnelResult(hit(counts), def(counts.length));
describe("previousWindow", () => {
it("is the same length and ends exactly where the current window starts", () => {
const cur = { startUs: OCT12, endUs: OCT12 + 4 * DAY_US };
expect(previousWindow(cur)).toEqual({ startUs: OCT12 - 4 * DAY_US, endUs: OCT12 });
});
it("works for a sub-day window", () => {
const cur = { startUs: OCT12 + 9 * HOUR_US, endUs: OCT12 + 9 * HOUR_US + 900_000_000 };
expect(previousWindow(cur)).toEqual({
startUs: OCT12 + 9 * HOUR_US - 900_000_000,
endUs: OCT12 + 9 * HOUR_US,
});
});
});
describe("compareAllowed", () => {
it("allows up to 31 days and refuses longer or empty ranges", () => {
expect(compareAllowed({ startUs: 0, endUs: COMPARE_MAX_US })).toBe(true);
expect(compareAllowed({ startUs: 0, endUs: COMPARE_MAX_US + 1 })).toBe(false);
expect(compareAllowed({ startUs: 5, endUs: 5 })).toBe(false);
});
});
describe("compareWindow", () => {
const week = { startUs: OCT12, endUs: OCT12 + 7 * DAY_US };
it("is the previous period for prev", () => {
expect(compareWindow(week, "prev")).toEqual(previousWindow(week));
});
it("shifts both ends back by the fixed offset, keeping the length", () => {
expect(compareWindow(week, "1h")).toEqual({
startUs: OCT12 - HOUR_US,
endUs: OCT12 + 7 * DAY_US - HOUR_US,
});
expect(compareWindow(week, "1d")).toEqual({
startUs: OCT12 - DAY_US,
endUs: OCT12 + 6 * DAY_US,
});
expect(compareWindow(week, "1w")).toEqual({ startUs: OCT12 - 7 * DAY_US, endUs: OCT12 });
});
});
describe("compareModeState", () => {
const span = (us: number) => ({ startUs: OCT12, endUs: OCT12 + us });
it("is always ok for prev", () => {
expect(compareModeState(span(7 * DAY_US), "prev")).toBe("ok");
expect(compareModeState(span(HOUR_US / 4), "prev")).toBe("ok");
});
it("is overlaps when the offset is shorter than the range", () => {
expect(compareModeState(span(7 * DAY_US), "1h")).toBe("overlaps");
expect(compareModeState(span(7 * DAY_US), "1d")).toBe("overlaps");
expect(compareModeState(span(DAY_US + 1), "1d")).toBe("overlaps");
});
it("is same when the offset equals the range", () => {
expect(compareModeState(span(7 * DAY_US), "1w")).toBe("same");
expect(compareModeState(span(DAY_US), "1d")).toBe("same");
expect(compareModeState(span(HOUR_US), "1h")).toBe("same");
});
it("is ok when the offset is longer than the range", () => {
expect(compareModeState(span(DAY_US), "1w")).toBe("ok");
expect(compareModeState(span(DAY_US), "1h")).toBe("overlaps");
expect(compareModeState(span(HOUR_US / 4), "1h")).toBe("ok");
expect(compareModeState(span(12 * HOUR_US), "1d")).toBe("ok");
});
});
describe("rateChange", () => {
it("gives the change in points and a 95% interval of the difference", () => {
const c = rateChange(26, 67, 7, 20);
expect(c?.pts).toBeCloseTo((26 / 67 - 7 / 20) * 100, 6);
const se = Math.sqrt(((26 / 67) * (41 / 67)) / 67 + (0.35 * 0.65) / 20);
expect(c?.ci95).toBeCloseTo(1.96 * se * 100, 6);
expect(c?.curN).toBe(67);
expect(c?.prevN).toBe(20);
});
it("is null when either period has nobody at the step above", () => {
expect(rateChange(0, 0, 3, 10)).toBeNull();
expect(rateChange(3, 10, 0, 0)).toBeNull();
});
it("marks low volume below 30 in either period, and not at exactly 30", () => {
expect(rateChange(10, LOW_VOLUME_MIN, 10, LOW_VOLUME_MIN)?.lowVolume).toBe(false);
expect(rateChange(10, LOW_VOLUME_MIN - 1, 10, 100)?.lowVolume).toBe(true);
expect(rateChange(10, 100, 10, LOW_VOLUME_MIN - 1)?.lowVolume).toBe(true);
});
it("reads a change that rounds to 0.0 as flat", () => {
const flat = rateChange(1000, 2000, 1001, 2000);
expect(flat && changeDirection(flat)).toBe("flat");
const up = rateChange(60, 100, 50, 100);
expect(up && changeDirection(up)).toBe("up");
const down = rateChange(40, 100, 50, 100);
expect(down && changeDirection(down)).toBe("down");
});
});
describe("compareFunnel", () => {
it("never changes the current numbers", () => {
const cur = result([67, 55, 44]);
const before = JSON.parse(JSON.stringify(cur));
compareFunnel(cur, result([20, 14, 10]));
expect(cur).toEqual(before);
});
it("gives step 1 no previous rate or change, and later steps step-to-step changes", () => {
const c = compareFunnel(result([67, 55, 44]), result([20, 14, 10]));
expect(c.empty).toBe(false);
expect(c.steps[0]).toEqual({
prevUnits: 20,
prevOfPrevious: null,
prevOfFirst: 1,
change: null,
});
expect(c.steps[1].prevOfPrevious).toBeCloseTo(0.7, 6);
expect(c.steps[1].prevOfFirst).toBeCloseTo(0.7, 6);
expect(c.steps[1].change?.pts).toBeCloseTo((55 / 67 - 0.7) * 100, 6);
expect(c.steps[2].prevOfPrevious).toBeCloseTo(10 / 14, 6);
expect(c.overall?.pts).toBeCloseTo((44 / 67 - 0.5) * 100, 6);
expect(c.prevOverall).toBeCloseTo(0.5, 6);
});
it("compares each drop-off with the previous period's, red when the drop rate rose", () => {
const c = compareFunnel(result([100, 50, 45]), result([100, 80, 30]));
expect(c.drops[0]).toMatchObject({ prevCount: 20, prevOutOf: 100, rose: true });
expect(c.drops[0]?.prevRate).toBeCloseTo(0.2, 6);
expect(c.drops[1]).toMatchObject({ prevCount: 50, prevOutOf: 80, rose: false });
});
it("greys a drop-off under the low-volume rule", () => {
const c = compareFunnel(result([67, 55]), result([20, 14]));
expect(c.drops[0]?.lowVolume).toBe(true);
});
it("an empty previous period gives no previous value anywhere", () => {
const c = compareFunnel(result([84, 67, 52]), result([0, 0, 0]));
expect(c.empty).toBe(true);
expect(c.steps.every((s) => s.prevUnits === null && s.change === null)).toBe(true);
expect(c.steps.every((s) => s.prevOfFirst === null && s.prevOfPrevious === null)).toBe(true);
expect(c.drops.every((d) => d === null)).toBe(true);
expect(c.overall).toBeNull();
expect(c.prevOverall).toBeNull();
});
it("a previous step reaching nobody mid-funnel leaves the steps after it without rates", () => {
const c = compareFunnel(result([50, 40, 30, 20]), result([10, 0, 0, 0]));
expect(c.steps[1].prevOfPrevious).toBe(0);
expect(c.steps[1].prevOfFirst).toBe(0);
expect(c.steps[2].prevOfPrevious).toBeNull();
expect(c.steps[2].prevOfFirst).toBeNull();
expect(c.steps[2].change).toBeNull();
expect(c.steps[3].prevOfFirst).toBeNull();
expect(c.drops[1]).toBeNull();
});
});
describe("funnelResultOf", () => {
it("reads one row without a breakdown and sums the values with one", () => {
expect(funnelResultOf([hit([10, 4])], def(2))?.steps.map((s) => s.units)).toEqual([10, 4]);
expect(funnelResultOf([], def(2))).toBeNull();
const rows = [
{ dim: "Chrome", c1: 6, c2: 3 },
{ dim: "Firefox", c1: 4, c2: 1 },
];
expect(funnelResultOf(rows, def(2, "browser"))?.steps.map((s) => s.units)).toEqual([10, 4]);
});
});
describe("compareBreakdown", () => {
const curHits = [
{ dim: "card", c1: 17, c2: 17 },
{ dim: "upi", c1: 8, c2: 8 },
{ dim: "cod", c1: 7, c2: 7 },
];
const cur = foldBreakdown(curHits, 2);
it("compares each value's overall conversion and the total", () => {
const res = compareBreakdown(
cur,
curHits,
[
{ dim: "card", c1: 40, c2: 20 },
{ dim: "upi", c1: 10, c2: 10 },
],
2,
);
const card = res.rows.find((r) => r.value === "card");
expect(card?.prev?.conversion).toBeCloseTo(0.5, 6);
expect(card?.prev?.change?.pts).toBeCloseTo(50, 6);
expect(res.total?.first).toBe(50);
expect(res.total?.last).toBe(30);
expect(res.total?.change?.pts).toBeCloseTo((32 / 32 - 30 / 50) * 100, 6);
});
it("keeps a value seen only this period with no previous side", () => {
const res = compareBreakdown(cur, curHits, [{ dim: "card", c1: 10, c2: 5 }], 2);
const cod = res.rows.find((r) => r.value === "cod");
expect(cod?.prev).toBeNull();
expect(cod?.prevOnly).toBe(false);
});
it("gives a value seen only last period a row of its own", () => {
const res = compareBreakdown(cur, curHits, [{ dim: "paypal", c1: 4, c2: 2 }], 2);
const paypal = res.rows.find((r) => r.value === "paypal");
expect(paypal?.prevOnly).toBe(true);
expect(paypal?.counts).toEqual([0, 0]);
expect(paypal?.prev?.conversion).toBeCloseTo(0.5, 6);
expect(paypal?.prev?.change).toBeNull();
});
it("compares a value folded into this period's Other inside Other, and keeps a new one apart", () => {
const manyHits = ["a", "b", "c", "d", "e", "f"].map((dim, i) => ({ dim, c1: 20 - i, c2: 10 }));
const res = compareBreakdown(
foldBreakdown(manyHits, 2),
manyHits,
[
{ dim: "f", c1: 5, c2: 1 },
{ dim: "z", c1: 5, c2: 4 },
],
2,
);
expect(res.rows.find((r) => r.other && !r.prevOnly)?.prev).toMatchObject({ first: 5, last: 1 });
expect(res.rows.find((r) => r.value === "z")).toMatchObject({ prevOnly: true });
});
it("never folds a named previous value into this period's (not set) row", () => {
const hits = [
{ dim: "Chrome", c1: 50, c2: 25 },
{ dim: null, c1: 10, c2: 5 },
];
const res = compareBreakdown(
foldBreakdown(hits, 2),
hits,
[
{ dim: "Chrome", c1: 40, c2: 20 },
{ dim: "Edge", c1: 200, c2: 100 },
{ dim: "", c1: 4, c2: 1 },
],
2,
);
expect(res.rows.find((r) => r.notSet && !r.prevOnly)?.prev).toMatchObject({
first: 4,
last: 1,
});
expect(res.rows.find((r) => r.value === "Edge")).toMatchObject({
prevOnly: true,
prev: { first: 200, last: 100 },
});
});
it("caps previous-only rows and folds the overflow into a previous-only Other", () => {
const prev = ["p1", "p2", "p3", "p4", "p5", "p6", "p7"].map((dim, i) => ({
dim,
c1: 10 - i,
c2: 1,
}));
const res = compareBreakdown(cur, curHits, prev, 2);
expect(res.rows.filter((r) => r.prevOnly && r.value !== null)).toHaveLength(5);
expect(res.rows.find((r) => r.prevOnly && r.other)?.prev?.first).toBe(5 + 4);
});
it("an empty previous period gives no previous conversion or change, Total included", () => {
const res = compareBreakdown(cur, curHits, [], 2);
expect(res.rows.every((r) => r.prev === null)).toBe(true);
expect(res.total?.conversion).toBeNull();
expect(res.total?.change).toBeNull();
});
it("applies the 30 rule to each value", () => {
const res = compareBreakdown(cur, curHits, [{ dim: "card", c1: 30, c2: 15 }], 2);
expect(res.rows.find((r) => r.value === "card")?.prev?.change?.lowVolume).toBe(true);
});
});
describe("formatWindow", () => {
it("prints whole days with an exclusive end", () => {
expect(formatWindow({ startUs: OCT12 - 4 * DAY_US, endUs: OCT12 }, "UTC")).toBe("8–11 Oct");
});
it("names both months when the window crosses one", () => {
const start = Date.UTC(2026, 8, 28) * 1000;
expect(formatWindow({ startUs: start, endUs: start + 7 * DAY_US }, "UTC")).toBe(
"28 Sep – 4 Oct",
);
});
it("prints times for a window under a day", () => {
const start = OCT12 + 9 * HOUR_US;
expect(formatWindow({ startUs: start, endUs: start + 4 * HOUR_US }, "UTC")).toBe(
"12 Oct, 09:00–13:00",
);
});
it("follows the viewer's time zone", () => {
expect(formatWindow({ startUs: OCT12, endUs: OCT12 + DAY_US }, "Asia/Kolkata")).toBe(
"12–13 Oct",
);
expect(formatWindowExact({ startUs: OCT12, endUs: OCT12 + DAY_US }, "UTC")).toBe(
"12 Oct 2026, 00:00 – 13 Oct 2026, 00:00",
);
});
});
+346
View File
@@ -0,0 +1,346 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { formatInTimeZone } from "date-fns-tz";
import type { I18nKey, I18nText } from "@/types/i18n";
import { COMPARE_OFFSET_MS, type CompareOffset } from "@/utils/metrics/explorerUrlState";
import {
BREAKDOWN_TOP,
hitCounts,
isNamedDim,
type BreakdownRow,
type BreakdownStart,
type FunnelResult,
} from "@/utils/rum/productAnalyticsModel";
const DAY_US = 86_400_000_000;
/** Compare doubles the scan, so it is offered only up to this range. */
export const COMPARE_MAX_US = 31 * DAY_US;
export const COMPARE_MODE_LABEL_KEYS: Record<FunnelCompareMode, I18nKey> = {
prev: "rum.analytics.funnel.comparePrev",
"1h": "rum.analytics.funnel.compareAgo1h",
"1d": "rum.analytics.funnel.compareAgo1d",
"1w": "rum.analytics.funnel.compareAgo1w",
};
export const FUNNEL_COMPARE_MODES: readonly FunnelCompareMode[] = ["prev", "1h", "1d", "1w"];
/** Below this many units at the step above, in either period, a change reads as noise. */
export const LOW_VOLUME_MIN = 30;
const Z95 = 1.96;
/** What the funnel compares with: the previous period, or the range shifted back by a fixed offset. */
export type FunnelCompareMode = "prev" | CompareOffset;
/** Why an offset cannot be used on a range, or "ok". */
export type CompareModeState = "ok" | "overlaps" | "same";
export interface UsWindow {
startUs: number;
endUs: number;
}
/** A change in a rate between two periods, in percentage points. */
export interface RateChange {
pts: number;
ci95: number;
lowVolume: boolean;
curN: number;
prevN: number;
}
export type ChangeDirection = "up" | "down" | "flat";
/** One step's previous-period numbers; null is a value the previous period cannot give. */
export interface StepCompare {
prevUnits: number | null;
prevOfPrevious: number | null;
prevOfFirst: number | null;
change: RateChange | null;
}
/** The drop between step i and i + 1 in the previous period, beside this period's. */
export interface DropCompare {
prevRate: number;
prevCount: number;
prevOutOf: number;
rose: boolean;
lowVolume: boolean;
}
export interface FunnelCompare {
empty: boolean;
steps: StepCompare[];
drops: (DropCompare | null)[];
overall: RateChange | null;
prevOverall: number | null;
}
/** What the funnel page hands its tables while compare is on. */
export interface FunnelCompareView {
status: "loading" | "failed" | "ok";
data: FunnelCompare | null;
label: I18nText;
range: string;
prevRange: string;
rangeExact: string;
prevRangeExact: string;
}
/** A breakdown value's overall conversion in the previous period. */
export interface PrevSide {
first: number;
last: number;
conversion: number | null;
change: RateChange | null;
}
export interface ComparedBreakdownRow extends BreakdownRow {
prev: PrevSide | null;
prevOnly: boolean;
}
/** What the funnel page hands its breakdown table while compare is on; rows carry their own previous side. */
export interface BreakdownCompareState {
status: "loading" | "failed" | "ok";
total: PrevSide | null;
label: I18nText;
prevRange: string;
}
/** The window of the same length that ends where `cur` starts, so the two never overlap. */
export function previousWindow(cur: UsWindow): UsWindow {
return { startUs: cur.startUs - (cur.endUs - cur.startUs), endUs: cur.startUs };
}
export function compareWindow(cur: UsWindow, mode: FunnelCompareMode): UsWindow {
if (mode === "prev") return previousWindow(cur);
const off = COMPARE_OFFSET_MS[mode] * 1000;
return { startUs: cur.startUs - off, endUs: cur.endUs - off };
}
export function compareModeState(cur: UsWindow, mode: FunnelCompareMode): CompareModeState {
if (mode === "prev") return "ok";
const off = COMPARE_OFFSET_MS[mode] * 1000;
const len = cur.endUs - cur.startUs;
if (off < len) return "overlaps";
return off === len ? "same" : "ok";
}
export function compareAllowed(cur: UsWindow): boolean {
return cur.endUs > cur.startUs && cur.endUs - cur.startUs <= COMPARE_MAX_US;
}
/** The mode a range actually compares with: an offset the range cannot use falls back to the previous period. */
export function effectiveCompareMode(
cur: UsWindow | null,
mode: FunnelCompareMode | null,
): FunnelCompareMode | null {
if (!mode || !cur || !compareAllowed(cur)) return null;
return compareModeState(cur, mode) === "ok" ? mode : "prev";
}
/** `x` of `n` now against `px` of `pn` before; null when either period has nobody at the step above. */
export function rateChange(x: number, n: number, px: number, pn: number): RateChange | null {
if (n <= 0 || pn <= 0) return null;
const p = x / n;
const pp = px / pn;
const se = Math.sqrt((p * (1 - p)) / n + (pp * (1 - pp)) / pn);
return {
pts: (p - pp) * 100,
ci95: Z95 * se * 100,
lowVolume: n < LOW_VOLUME_MIN || pn < LOW_VOLUME_MIN,
curN: n,
prevN: pn,
};
}
/** Direction as printed, so a change that rounds to 0.0 never gets an arrow. */
export function changeDirection(c: RateChange): ChangeDirection {
const r = Math.round(c.pts * 10) / 10;
if (r > 0) return "up";
return r < 0 ? "down" : "flat";
}
export const formatPts = (pts: number): string => Math.abs(pts).toFixed(1);
export function compareFunnel(cur: FunnelResult, prev: FunnelResult): FunnelCompare {
const pu = prev.steps.map((s) => s.units);
const first = pu[0] ?? 0;
const empty = first === 0;
// Once a previous step reaches nobody, every step after it has no rate to give.
const zeroAt = pu.findIndex((u) => u === 0);
const steps: StepCompare[] = cur.steps.map((s, i) => {
if (empty || pu[i] === undefined)
return { prevUnits: null, prevOfPrevious: null, prevOfFirst: null, change: null };
const above = i > 0 ? pu[i - 1] : 0;
const after = zeroAt >= 0 && i > zeroAt;
return {
prevUnits: pu[i],
prevOfPrevious: i > 0 && above > 0 ? pu[i] / above : null,
prevOfFirst: after ? null : pu[i] / first,
change: i > 0 ? rateChange(s.units, cur.steps[i - 1].units, pu[i], above) : null,
};
});
const drops = cur.steps.slice(0, -1).map((_, i): DropCompare | null => {
const change = steps[i + 1]?.change;
if (empty || !pu[i] || pu[i + 1] === undefined) return null;
return {
prevRate: 1 - pu[i + 1] / pu[i],
prevCount: pu[i] - pu[i + 1],
prevOutOf: pu[i],
rose: !!change && changeDirection(change) === "down",
lowVolume: !!change?.lowVolume,
};
});
const n = cur.steps.length;
const last = n ? cur.steps[n - 1].units : 0;
const prevLast = pu[n - 1] ?? 0;
return {
empty,
steps,
drops,
overall: n > 1 ? rateChange(last, cur.steps[0].units, prevLast, first) : null,
prevOverall: empty ? null : prevLast / first,
};
}
/** Lines the previous run's per-value rows up with this period's folded rows; only overall conversion is compared. */
export function compareBreakdown(
cur: { rows: BreakdownRow[]; total: number[]; start?: BreakdownStart | null },
curHits: Record<string, unknown>[],
prevHits: Record<string, unknown>[],
steps: number,
): { rows: ComparedBreakdownRow[]; total: PrevSide | null } {
const k = cur.start && !cur.start.anyStep ? cur.start.step : 1;
const head = new Set(cur.rows.flatMap((r) => (r.value === null ? [] : [r.value])));
// An attribute breakdown from a start step keeps its unnamed units in their own row, apart from Other.
const split = !!cur.start && !cur.start.anyStep;
// Only a value with units at step k is in this period's Other; the rest show as previous-only rows.
const folded = split
? cur.rows.some((r) => r.other)
? curHits.filter((h) => isNamedDim(h) && hitCounts(h, k)[k - 1] > 0)
: []
: curHits.filter(isNamedDim);
const seenNow = new Set(folded.map((h) => String(h.dim)));
const restRow = cur.rows.find((r) => r.value === null && (split ? r.noValue : true));
const byValue = new Map<string, Record<string, unknown>[]>();
const restNamed: Record<string, unknown>[] = [];
const restUnnamed: Record<string, unknown>[] = [];
const prevOnly: Record<string, unknown>[] = [];
for (const h of prevHits) {
const dim = String(h.dim);
if (isNamedDim(h) && head.has(dim)) byValue.set(dim, [...(byValue.get(dim) ?? []), h]);
// A value folded into this period's Other is compared there; an unnamed one goes to this period's null row.
else if (isNamedDim(h) && seenNow.has(dim)) restNamed.push(h);
else if (!isNamedDim(h) && restRow) restUnnamed.push(h);
else prevOnly.push(h);
}
const hitsOf = (r: BreakdownRow): Record<string, unknown>[] | null => {
if (r.leftBefore) return null;
if (r.value !== null) return byValue.get(r.value) ?? [];
if (r.noValue) return restUnnamed;
return split ? restNamed : [...restNamed, ...restUnnamed];
};
const rows: ComparedBreakdownRow[] = cur.rows.map((r) => {
const hits = hitsOf(r);
return {
...r,
prev: prevSide(
r.counts.slice(k - 1),
hits?.length ? sumCounts(hits, steps).slice(k - 1) : null,
),
prevOnly: false,
};
});
const atK = (h: Record<string, unknown>) => hitCounts(h, k)[k - 1];
const named = prevOnly.filter(isNamedDim).sort((a, b) => atK(b) - atK(a));
const own = named.slice(0, BREAKDOWN_TOP);
rows.push(...own.map((h) => prevOnlyRow([h], steps, String(h.dim), k)));
const overflow = named.slice(BREAKDOWN_TOP);
if (overflow.length) rows.push(prevOnlyRow(overflow, steps, null, k));
const unnamed = prevOnly.filter((h) => !isNamedDim(h));
if (unnamed.length) rows.push(prevOnlyRow(unnamed, steps, null, k));
return {
rows,
total: prevSide(cur.total.slice(k - 1), sumCounts(prevHits, steps).slice(k - 1)),
};
}
/** A short range label ("8–11 Oct"); a range under a day shows times instead of days. */
export function formatWindow(w: UsWindow, tz: string): string {
if (w.endUs - w.startUs < DAY_US) {
const sameDay = fmt(w.startUs, tz, "yyyyMMdd") === fmt(w.endUs, tz, "yyyyMMdd");
return sameDay
? `${fmt(w.startUs, tz, "d MMM, HH:mm")}–${fmt(w.endUs, tz, "HH:mm")}`
: `${fmt(w.startUs, tz, "d MMM HH:mm")} – ${fmt(w.endUs, tz, "d MMM HH:mm")}`;
}
// The end is exclusive, so the last day shown is the one holding the window's final instant.
const lastUs = w.endUs - 1;
if (fmt(w.startUs, tz, "yyyy") !== fmt(lastUs, tz, "yyyy"))
return `${fmt(w.startUs, tz, "d MMM yyyy")} – ${fmt(lastUs, tz, "d MMM yyyy")}`;
if (fmt(w.startUs, tz, "MM") !== fmt(lastUs, tz, "MM"))
return `${fmt(w.startUs, tz, "d MMM")} – ${fmt(lastUs, tz, "d MMM")}`;
if (fmt(w.startUs, tz, "d") === fmt(lastUs, tz, "d")) return fmt(w.startUs, tz, "d MMM");
return `${fmt(w.startUs, tz, "d")}–${fmt(lastUs, tz, "d MMM")}`;
}
export function formatWindowExact(w: UsWindow, tz: string): string {
return `${fmt(w.startUs, tz, "d MMM yyyy, HH:mm")} – ${fmt(w.endUs, tz, "d MMM yyyy, HH:mm")}`;
}
const sumCounts = (hits: Record<string, unknown>[], steps: number): number[] =>
hits.reduce<number[]>(
(acc, h) => acc.map((v, i) => v + hitCounts(h, steps)[i]),
Array(steps).fill(0),
);
const prevSide = (now: number[], before: number[] | null): PrevSide | null => {
if (!before) return null;
const k = before.length - 1;
const first = before[0] ?? 0;
const last = before[k] ?? 0;
return {
first,
last,
conversion: first > 0 ? last / first : null,
change: rateChange(now[k] ?? 0, now[0] ?? 0, last, first),
};
};
const prevOnlyRow = (
hits: Record<string, unknown>[],
steps: number,
value: string | null,
k: number,
): ComparedBreakdownRow => {
const zeros: number[] = Array(steps).fill(0);
const notSet = value === null && hits.every((h) => !isNamedDim(h));
return {
value,
label: value ?? "",
counts: zeros,
conversion: 0,
atLeast: false,
other: value === null && !notSet,
notSet,
noValue: false,
leftBefore: false,
medianMs: null,
prev: prevSide(zeros.slice(k - 1), sumCounts(hits, steps).slice(k - 1)),
prevOnly: true,
};
};
const fmt = (us: number, tz: string, pattern: string): string =>
formatInTimeZone(new Date(us / 1000), tz, pattern);
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { fieldsOnStep, foldFieldSample, isSampledField } from "@/utils/rum/funnelFields";
import type { StepRef } from "@/utils/rum/productAnalyticsQueries";
const c = (key: string): StepRef => ({ kind: "c", key });
const STEPS = [c("product_viewed"), c("payment_info_submitted"), c("order_completed")];
const FIELDS = ["context_category", "context_coupon", "context_payment_method"];
describe("isSampledField", () => {
it("keeps the app's own columns and leaves the SDK's out", () => {
expect(isSampledField("context_payment_method")).toBe(true);
expect(isSampledField("plan")).toBe(true);
for (const f of [
"view_url",
"action_id",
"usr_email",
"user_agent_os_family",
"date",
"o2tags",
])
expect(isSampledField(f)).toBe(false);
expect(isSampledField("geo_info_country")).toBe(false);
expect(isSampledField("env")).toBe(false);
});
});
describe("foldFieldSample", () => {
const rows = [
{ s1: 1, s2: 0, s3: 0, context_category: "shoes" },
{ s1: 1, s2: 0, s3: 0, context_category: "bags" },
{ s1: 1, s2: 0, s3: 0, context_category: "shoes" },
{ s1: 0, s2: 1, s3: 0, context_payment_method: "card" },
{ s1: 0, s2: 1, s3: 0, context_payment_method: "upi" },
{ s1: 0, s2: 1, s3: 0, context_payment_method: "card" },
{ s1: 0, s2: 1, s3: 0, context_payment_method: "cod" },
{ s1: 0, s2: 1, s3: 0, context_payment_method: "paypal" },
{ s1: 0, s2: 0, s3: 1, context_payment_method: "card", context_coupon: "" },
];
it("lists each field at the first step that carries it, with its top values there", () => {
const { fields } = foldFieldSample(rows, STEPS, FIELDS);
expect(fields.map((f) => [f.field, f.steps, f.top, f.more, f.many])).toEqual([
["context_category", [1], ["shoes", "bags"], 0, false],
["context_payment_method", [2, 3], ["card", "cod", "paypal"], 1, false],
]);
});
it("flags a field with more distinct values than a breakdown can show", () => {
const many = Array.from({ length: 51 }, (_, i) => ({
s1: 1,
s2: 0,
s3: 0,
context_category: `c${i}`,
}));
expect(foldFieldSample(many, STEPS, FIELDS).fields[0]).toMatchObject({ many: true });
});
it("gives a row that matches two steps to both", () => {
const both = [{ s1: 1, s2: 1, s3: 0, context_category: "shoes" }];
const { fields } = foldFieldSample(both, STEPS, FIELDS);
expect(fields[0]).toMatchObject({ field: "context_category", steps: [1, 2] });
});
it("limits a step's list to the fields its events carry", () => {
const sample = foldFieldSample(rows, STEPS, FIELDS);
expect(fieldsOnStep(sample, 3).map((f) => f.field)).toEqual(["context_payment_method"]);
expect(fieldsOnStep(null, 1)).toEqual([]);
});
});
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { isFilterColumn } from "@/utils/rum/productAnalyticsModel";
import type { StepRef } from "@/utils/rum/productAnalyticsQueries";
/** A field seen on the funnel's step events in the per-step sample. */
export interface SeenField {
field: string;
/** 1-based steps whose sampled rows carry the field, in order. */
steps: number[];
/** Most frequent values at the first step that carries it. */
top: string[];
/** Distinct values at that step beyond `top`. */
more: number;
/** More distinct values in the sample than a breakdown can show. */
many: boolean;
}
export interface FieldSample {
fields: SeenField[];
}
export const FIELD_SAMPLE_ROWS = 200;
export const MANY_VALUES = 50;
const TOP_VALUES = 3;
// The RUM SDK writes these on every row of a kind, so they never say what a step's events carry.
const SDK_PREFIXES = [
"o2",
"action_",
"view_",
"session_",
"resource_",
"long_task_",
"error_",
"telemetry_",
"display_",
"connectivity_",
"user_agent_",
"device_",
"geo_info_",
"privacy_",
"usr_",
];
const SDK_FIELDS = [
"date",
"type",
"source",
"service",
"sdk_version",
"tab_id",
"ip",
"batch_time",
"application_id",
"anonymous_id",
];
/** A column the per-step sample reads: one the app sets itself, which a breakdown or step condition can use. */
export function isSampledField(name: string): boolean {
return (
isFilterColumn(name) &&
!SDK_FIELDS.includes(name) &&
!SDK_PREFIXES.some((p) => name.startsWith(p))
);
}
const text = (v: unknown): string | null =>
v === null || v === undefined || v === "" ? null : String(v);
const valueCounts = (rows: Record<string, unknown>[], field: string): Map<string, number> => {
const counts = new Map<string, number>();
for (const r of rows) {
const v = text(r[field]);
if (v !== null) counts.set(v, (counts.get(v) ?? 0) + 1);
}
return counts;
};
/** Folds the sample's rows into the fields each step's events carry, ordered by the first step that sets them. */
export function foldFieldSample(
rows: Record<string, unknown>[],
steps: StepRef[],
fields: readonly string[],
): FieldSample {
const stepRows = steps.map((_, i) => rows.filter((r) => Number(r[`s${i + 1}`]) === 1));
const seen: SeenField[] = [];
for (const field of fields) {
const per = steps.map((_, i) => valueCounts(stepRows[i], field));
const at = per.map((c, i) => (c.size ? i + 1 : 0)).filter((s) => s > 0);
if (!at.length) continue;
const first = per[at[0] - 1];
const ranked = [...first.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
const all = new Set(per.flatMap((c) => [...c.keys()]));
seen.push({
field,
steps: at,
top: ranked.slice(0, TOP_VALUES).map(([v]) => v),
more: Math.max(0, ranked.length - TOP_VALUES),
many: all.size > MANY_VALUES,
});
}
seen.sort((a, b) => a.steps[0] - b.steps[0] || (a.field < b.field ? -1 : 1));
return { fields: seen };
}
export function fieldsOnStep(sample: FieldSample | null, step: number): SeenField[] {
return sample?.fields.filter((f) => f.steps.includes(step)) ?? [];
}
+277
View File
@@ -0,0 +1,277 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
TREND_LOW_VOLUME,
alignPrevious,
bucketLabel,
foldTrend,
isTrendEmpty,
pairParam,
parseInterval,
parsePair,
previousTrendPeriods,
resolveInterval,
resolvePair,
trendAverage,
trendPeriods,
type TrendContext,
type TrendPeriods,
} from "@/utils/rum/funnelTrend";
const HOUR = 3_600_000_000;
const DAY = 24 * HOUR;
// 8 Oct 2026 00:00 UTC.
const D8 = Date.UTC(2026, 9, 8) * 1000;
const periodsOf = (startUs: number, days: number, nowUs: number, tz = "UTC"): TrendPeriods => {
const p = trendPeriods({ startUs, endUs: startUs + days * DAY }, tz, nowUs, "day");
if (!("boundariesUs" in p)) throw new Error("expected periods");
return p;
};
const ctx = (patch: Partial<TrendContext> = {}): TrendContext => ({
range: { startUs: D8, endUs: D8 + 8 * DAY },
nowUs: D8 + 7 * DAY + 10 * HOUR,
dataStartUs: null,
dataThroughUs: null,
windowUs: null,
...patch,
});
describe("funnel trend link params", () => {
it("reads a 1-based pair, and only a forward one", () => {
expect(parsePair("1-6")).toEqual({ a: 1, b: 6 });
expect(parsePair("2-3")).toEqual({ a: 2, b: 3 });
for (const bad of ["6-1", "3-3", "0-2", "a-b", "1-", "", undefined, 7]) {
expect(parsePair(bad)).toBeNull();
}
expect(pairParam({ a: 2, b: 5 })).toBe("2-5");
});
it("falls back to step 1 → the last step when the funnel cannot hold the pair", () => {
expect(resolvePair(null, 6)).toEqual({ a: 1, b: 6 });
expect(resolvePair({ a: 2, b: 4 }, 6)).toEqual({ a: 2, b: 4 });
expect(resolvePair({ a: 2, b: 7 }, 6)).toEqual({ a: 1, b: 6 });
expect(resolvePair({ a: 1, b: 3 }, 2)).toEqual({ a: 1, b: 2 });
});
it("reads day, week and month; anything else is Auto", () => {
expect(parseInterval("week")).toBe("week");
expect(parseInterval("hour")).toBe("auto");
expect(parseInterval(undefined)).toBe("auto");
});
it("resolves Auto like Retention, and a pick the range rules out falls back to Auto", () => {
const week = { startUs: D8, endUs: D8 + 7 * DAY };
expect(resolveInterval(week, "UTC", null, "auto")).toBe("day");
expect(resolveInterval(week, "UTC", null, "week")).toBe("week");
expect(resolveInterval(week, "UTC", null, "month")).toBe("day");
const quarter = { startUs: D8, endUs: D8 + 60 * DAY };
expect(resolveInterval(quarter, "UTC", null, "week")).toBe("week");
expect(resolveInterval({ startUs: D8, endUs: D8 + HOUR }, "UTC", null, "auto")).toBeNull();
});
});
describe("trendPeriods", () => {
it("is too short below two buckets and too many past 36", () => {
expect(trendPeriods({ startUs: D8, endUs: D8 + DAY }, "UTC", D8, null)).toEqual({
tooShort: true,
});
expect(trendPeriods({ startUs: D8, endUs: D8 + 60 * DAY }, "UTC", D8, "day")).toEqual({
tooMany: true,
});
});
it("starts at local midnight in the viewer's zone and marks the bucket holding now", () => {
const p = periodsOf(D8 + 5 * HOUR, 3, D8 + 2 * DAY + 6 * HOUR, "Asia/Kolkata");
expect(p.boundariesUs[0]).toBe(D8 - 5.5 * HOUR);
expect(bucketLabel(p.boundariesUs[0], "Asia/Kolkata", "day")).toBe("8 Oct");
expect(p.runningIndex).toBe(p.boundariesUs.length - 1);
});
it("a DST week is a week of wall time, 169 hours long, with weeks from Monday", () => {
// US clocks fall back on Sunday 1 Nov 2026.
const start = Date.UTC(2026, 9, 26, 4) * 1000;
const p = trendPeriods(
{ startUs: start, endUs: start + 21 * DAY },
"America/New_York",
start,
"week",
);
if (!("boundariesUs" in p)) throw new Error("expected periods");
expect(p.boundariesUs[0]).toBe(start);
expect(p.endsUs[0] - p.boundariesUs[0]).toBe(7 * DAY + HOUR);
expect(p.endsUs[1] - p.boundariesUs[1]).toBe(7 * DAY);
expect(bucketLabel(p.boundariesUs[1], "America/New_York", "week")).toBe("2 Nov");
});
it("lines the previous window up at the same interval", () => {
const pp = previousTrendPeriods({ startUs: D8 - 4 * DAY, endUs: D8 }, "UTC", D8, "day");
expect(pp?.boundariesUs).toEqual([0, 1, 2, 3].map((i) => D8 - (4 - i) * DAY));
});
});
describe("foldTrend", () => {
const p = periodsOf(D8, 8, D8 + 7 * DAY + 10 * HOUR);
const rows = [
{ bk: 1, ca: 5, cb: 1 },
{ bk: 3, ca: 7, cb: 4 },
{ bk: 5, ca: 52, cb: 21 },
{ bk: 7, ca: 11, cb: 4 },
];
it("an empty bucket is a gap with no rate, never 0%", () => {
const [s] = foldTrend(rows, p, ctx(), false);
expect(s.buckets).toHaveLength(8);
expect(s.buckets[0]).toMatchObject({ entered: 0, reached: 0, rate: null, empty: true });
expect(s.buckets[5]).toMatchObject({ entered: 52, reached: 21, empty: false });
expect(s.buckets[5].rate).toBeCloseTo(21 / 52);
expect(isTrendEmpty([s])).toBe(false);
expect(isTrendEmpty(foldTrend([], p, ctx(), false))).toBe(true);
});
it("Sessions: only the bucket holding now is still converting", () => {
const [s] = foldTrend(rows, p, ctx(), false);
expect(s.buckets.map((b) => b.stillConverting)).toEqual([
false,
false,
false,
false,
false,
false,
false,
true,
]);
});
it("Users: every bucket newer than the conversion window is still converting", () => {
const [s] = foldTrend(rows, p, ctx({ windowUs: DAY }), false);
expect(s.buckets.map((b) => b.stillConverting)).toEqual([
false,
false,
false,
false,
false,
false,
true,
true,
]);
});
it("a bucket that ends before the first data reads as before data", () => {
const [s] = foldTrend(rows, p, ctx({ dataStartUs: D8 + DAY + HOUR }), false);
expect(s.buckets[0].beforeData).toBe(true);
expect(s.buckets[1].beforeData).toBe(false);
});
it("a recent bucket after the last ingested event reads as still arriving", () => {
const now = D8 + 7 * DAY + 10 * HOUR;
const lagging = ctx({ dataThroughUs: D8 + 6 * DAY - HOUR, nowUs: now });
const [s] = foldTrend(rows, p, lagging, false);
expect(s.buckets[6].arriving).toBe(true);
expect(s.buckets[7].arriving).toBe(false);
expect(s.buckets[5].arriving).toBe(false);
const old = ctx({ dataThroughUs: D8 + 2 * DAY, nowUs: D8 + 30 * DAY });
expect(foldTrend(rows, p, old, false)[0].buckets.some((b) => b.arriving)).toBe(false);
});
it(`fewer than ${TREND_LOW_VOLUME} entrants is low volume; none is not`, () => {
const [s] = foldTrend(rows, p, ctx(), false);
expect(s.buckets[1].lowVolume).toBe(true);
expect(s.buckets[5].lowVolume).toBe(false);
expect(s.buckets[0].lowVolume).toBe(false);
});
it("a rolling range's cut first bucket is partial", () => {
const rolling = periodsOf(D8 + 9 * HOUR, 7, D8 + 7 * DAY + 9 * HOUR);
const [s] = foldTrend(
rows,
rolling,
ctx({ range: { startUs: D8 + 9 * HOUR, endUs: D8 + 7 * DAY + 9 * HOUR } }),
false,
);
expect(s.buckets[0].partial).toBe(true);
expect(s.buckets[1].partial).toBe(false);
expect(s.buckets.at(-1)!.stillConverting).toBe(true);
expect(s.buckets.at(-1)!.partial).toBe(false);
});
it("with a breakdown, each value is a series and the folded rest is Other or (not set)", () => {
const hits = [
{ dim: "Chrome", un: 0, bk: 1, ca: 5, cb: 1 },
{ dim: "Chrome", un: 0, bk: 5, ca: 26, cb: 8 },
{ dim: "Firefox", un: 0, bk: 5, ca: 4, cb: 4 },
{ dim: null, un: 0, bk: 5, ca: 4, cb: 0 },
{ dim: null, un: 1, bk: 7, ca: 2, cb: 0 },
];
const series = foldTrend(hits, p, ctx(), true);
expect(series.map((s) => [s.kind, s.value])).toEqual([
["value", "Chrome"],
["value", "Firefox"],
["other", null],
]);
expect(series[2].buckets[5].entered).toBe(4);
const unnamed = foldTrend([{ dim: "", un: 1, bk: 2, ca: 3, cb: 1 }], p, ctx(), true);
expect(unnamed.map((s) => s.kind)).toEqual(["notSet"]);
});
it("orders breakdown values by the SQL's step-1 rank total, not by step-a entrants", () => {
const hits = [
{ dim: "Safari", un: 0, tot: 20, bk: 1, ca: 30, cb: 5 },
{ dim: "Chrome", un: 0, tot: 90, bk: 1, ca: 4, cb: 1 },
{ dim: null, un: 0, tot: 500, bk: 1, ca: 50, cb: 1 },
];
expect(foldTrend(hits, p, ctx(), true).map((s) => s.value)).toEqual(["Chrome", "Safari", null]);
const tied = [
{ dim: "chrome", un: 0, tot: 40, bk: 1, ca: 4, cb: 1 },
{ dim: "Safari", un: 0, tot: 40, bk: 1, ca: 4, cb: 1 },
];
// Byte order, like the SQL's DENSE_RANK and the Steps breakdown, puts "Safari" before "chrome".
expect(foldTrend(tied, p, ctx(), true).map((s) => s.value)).toEqual(["Safari", "chrome"]);
});
it("an attribute ranked at step k breaks ties by step-1 units before the name, like the table", () => {
const tied = [
{ dim: "card", un: 0, tot: 8, t1: 10, bk: 1, ca: 4, cb: 1 },
{ dim: "upi", un: 0, tot: 8, t1: 30, bk: 1, ca: 4, cb: 1 },
];
expect(foldTrend(tied, p, ctx(), true).map((s) => s.value)).toEqual(["upi", "card"]);
});
it("a breakdown value's average pools closed buckets and leaves the still-converting one out", () => {
const hits = [
{ dim: "Chrome", un: 0, bk: 1, ca: 10, cb: 5 },
{ dim: "Chrome", un: 0, bk: 3, ca: 30, cb: 6 },
{ dim: "Chrome", un: 0, bk: 7, ca: 100, cb: 0 },
];
const [chrome] = foldTrend(hits, p, ctx(), true);
expect(trendAverage(chrome)).toEqual({ entered: 40, reached: 11, rate: 11 / 40 });
const none = foldTrend([{ dim: "X", un: 0, bk: 7, ca: 3, cb: 1 }], p, ctx(), true)[0];
expect(trendAverage(none).rate).toBeNull();
});
it("lines the previous period up by bucket index", () => {
const [cur] = foldTrend(rows, p, ctx(), false);
const prevP = periodsOf(D8 - 8 * DAY, 8, D8 + 7 * DAY);
const [prev] = foldTrend([{ bk: 0, ca: 4, cb: 1 }], prevP, ctx(), false);
const aligned = alignPrevious(cur, prev);
expect(aligned).toHaveLength(8);
expect(aligned[0]).toEqual({ startUs: D8 - 8 * DAY, entered: 4, rate: 0.25 });
expect(aligned[1]).toEqual({ startUs: D8 - 7 * DAY, entered: 0, rate: null });
expect(alignPrevious(cur, null).every((x) => x === null)).toBe(true);
});
});
+279
View File
@@ -0,0 +1,279 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { formatInTimeZone } from "date-fns-tz";
import {
autoGranularity,
granularityOptions,
periodEndUs,
retentionPeriods,
type Period,
} from "@/utils/rum/productAnalyticsModel";
import type { FunnelPair, Granularity } from "@/utils/rum/productAnalyticsQueries";
import type { UsWindow } from "@/utils/rum/funnelCompare";
import type { I18nText } from "@/types/i18n";
const DAY_US = 86_400_000_000;
/** Below this many entrants a bucket's rate gets the low-volume tag. */
export const TREND_LOW_VOLUME = 10;
/** A breakdown's table pivots value × bucket up to this many buckets, and averages past it. */
export const PIVOT_MAX_BUCKETS = 8;
const PAIR_PARAM = /^([1-9][0-9]?)-([1-9][0-9]?)$/;
const REST_ID = "\u0000rest";
export type FunnelView = "steps" | "trend";
/** The calendar buckets a trend is drawn over; `runningIndex` is the bucket holding now. */
export interface TrendPeriods {
granularity: Period;
boundariesUs: number[];
endsUs: number[];
runningIndex: number | null;
}
export type TrendPeriodsResult = TrendPeriods | { tooShort: true } | { tooMany: true };
export interface TrendBucket {
index: number;
startUs: number;
endUs: number;
entered: number;
reached: number;
rate: number | null;
empty: boolean;
beforeData: boolean;
stillConverting: boolean;
arriving: boolean;
lowVolume: boolean;
partial: boolean;
}
export type TrendSeriesKind = "all" | "value" | "other" | "notSet";
export interface TrendSeries {
id: string;
value: string | null;
kind: TrendSeriesKind;
buckets: TrendBucket[];
}
/** What decides each bucket's flags besides its own counts. */
export interface TrendContext {
range: UsWindow;
nowUs: number;
dataStartUs: number | null;
dataThroughUs: number | null;
/** Users mode's conversion window; null when the unit converts within a session. */
windowUs: number | null;
}
/** A breakdown value's numbers over the buckets whose window has closed. */
export interface TrendAverage {
entered: number;
reached: number;
rate: number | null;
}
/** A previous-period bucket lined up with this period's bucket of the same index. */
export interface TrendPrevious {
startUs: number;
entered: number;
rate: number | null;
}
/** The previous period's run as the trend view shows it. */
export interface TrendPreviousView {
status: "loading" | "failed" | "ok";
buckets: (TrendPrevious | null)[];
range: string;
/** The compare mode's name, such as "1 week ago". */
label: I18nText;
}
export function parsePair(raw: unknown): FunnelPair | null {
if (typeof raw !== "string") return null;
const m = PAIR_PARAM.exec(raw);
if (!m) return null;
const a = Number(m[1]);
const b = Number(m[2]);
return a < b ? { a, b } : null;
}
export const pairParam = (p: FunnelPair): string => `${p.a}-${p.b}`;
/** A pair the funnel's steps cannot hold falls back to step 1 → the last step. */
export function resolvePair(pair: FunnelPair | null, steps: number): FunnelPair {
if (pair && pair.a >= 1 && pair.a < pair.b && pair.b <= steps) return pair;
return { a: 1, b: Math.max(2, steps) };
}
export function parseInterval(raw: unknown): Granularity {
return raw === "day" || raw === "week" || raw === "month" ? raw : "auto";
}
/** The interval a pick resolves to; a pick the range rules out falls back to Auto. */
export function resolveInterval(
range: UsWindow,
tz: string,
dataStartUs: number | null,
per: Granularity,
): Period | null {
const opts = granularityOptions(range.startUs, range.endUs, tz, dataStartUs);
const picked = per !== "auto" && !opts.find((o) => o.value === per)?.disabled ? per : null;
return picked ?? autoGranularity(range.startUs, range.endUs, dataStartUs);
}
/** Unclipped buckets, so a bucket before the app's first data still shows as one. */
export function trendPeriods(
range: UsWindow,
tz: string,
nowUs: number,
g: Period | null,
): TrendPeriodsResult {
if (!g) return { tooShort: true };
const p = retentionPeriods(range.startUs, range.endUs, tz, nowUs, null, g);
if (!("boundariesUs" in p)) return p;
const b = p.boundariesUs;
return {
granularity: p.granularity,
boundariesUs: b,
endsUs: b.map((_, i) => (i < b.length - 1 ? b[i + 1] : periodEndUs(b[i], tz, g))),
runningIndex: p.runningIndex,
};
}
/** The previous window's buckets at the same interval, so bucket i lines up with bucket i. */
export function previousTrendPeriods(
prev: UsWindow,
tz: string,
nowUs: number,
g: Period,
): TrendPeriods | null {
const p = trendPeriods(prev, tz, nowUs, g);
return "boundariesUs" in p ? p : null;
}
/** Folds the trend run's rows; with a breakdown each value is a series and the rest fold into one. */
export function foldTrend(
rows: Record<string, unknown>[],
periods: TrendPeriods,
ctx: TrendContext,
breakdown: boolean,
): TrendSeries[] {
if (!breakdown) return [toSeries("all", null, "all", rows, periods, ctx)];
const groups = new Map<string, Record<string, unknown>[]>();
for (const r of rows) {
const id = named(r.dim) ? String(r.dim) : REST_ID;
groups.set(id, [...(groups.get(id) ?? []), r]);
}
// The SQL ranked values by units at the table's start step, so order and colours match the Steps breakdown.
const total = (id: string) => Math.max(0, ...(groups.get(id) ?? []).map((r) => num(r.tot)));
const first = (id: string) => Math.max(0, ...(groups.get(id) ?? []).map((r) => num(r.t1)));
const ids = [...groups.keys()]
.filter((id) => id !== REST_ID)
.sort((x, y) => total(y) - total(x) || first(y) - first(x) || (x < y ? -1 : x > y ? 1 : 0));
const series = ids.map((id) => toSeries(id, id, "value", groups.get(id)!, periods, ctx));
const rest = groups.get(REST_ID);
if (rest) {
const kind = rest.every((r) => num(r.un) === 1) ? "notSet" : "other";
series.push(toSeries(REST_ID, null, kind, rest, periods, ctx));
}
return series;
}
/** Pools the closed buckets, so a still-converting bucket never drags the average down. */
export function trendAverage(s: TrendSeries): TrendAverage {
const kept = s.buckets.filter((b) => !b.stillConverting && !b.beforeData);
const entered = kept.reduce((n, b) => n + b.entered, 0);
const reached = kept.reduce((n, b) => n + b.reached, 0);
return { entered, reached, rate: entered > 0 ? reached / entered : null };
}
export function alignPrevious(
cur: TrendSeries,
prev: TrendSeries | null,
): (TrendPrevious | null)[] {
return cur.buckets.map((_, i) => {
const p = prev?.buckets[i];
return p ? { startUs: p.startUs, entered: p.entered, rate: p.rate } : null;
});
}
export const isTrendEmpty = (series: TrendSeries[]): boolean =>
series.every((s) => s.buckets.every((b) => b.entered === 0));
/** A bucket's label in the viewer's zone: "8 Oct" for days and weeks, "Oct 2026" for months. */
export function bucketLabel(us: number, tz: string, g: Period): string {
return formatInTimeZone(new Date(us / 1000), tz, g === "month" ? "MMM yyyy" : "d MMM");
}
const num = (v: unknown): number => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
const named = (v: unknown): boolean => v !== null && v !== undefined && v !== "";
const counts = (entered: number, reached: number) => ({
entered,
reached,
rate: entered > 0 ? Math.min(reached, entered) / entered : null,
empty: entered === 0,
lowVolume: entered > 0 && entered < TREND_LOW_VOLUME,
});
const toSeries = (
id: string,
value: string | null,
kind: TrendSeriesKind,
rows: Record<string, unknown>[],
periods: TrendPeriods,
ctx: TrendContext,
): TrendSeries => {
const by = new Map<number, { ca: number; cb: number }>();
for (const r of rows) {
const k = num(r.bk);
const cur = by.get(k) ?? { ca: 0, cb: 0 };
by.set(k, { ca: cur.ca + num(r.ca), cb: cur.cb + num(r.cb) });
}
const buckets = periods.boundariesUs.map((startUs, i) => {
const c = by.get(i) ?? { ca: 0, cb: 0 };
return { index: i, startUs, endUs: periods.endsUs[i], ...counts(c.ca, c.cb) };
});
return {
id,
value,
kind,
buckets: buckets.map((b) => ({ ...b, ...bucketFlags(b, periods, ctx) })),
};
};
const bucketFlags = (
b: { index: number; startUs: number; endUs: number },
periods: TrendPeriods,
ctx: TrendContext,
) => {
const running = periods.runningIndex === b.index;
// A unit entering near the bucket's end has until end + window to convert.
const stillConverting = running || (ctx.windowUs !== null && b.endUs + ctx.windowUs > ctx.nowUs);
const dt = ctx.dataThroughUs;
return {
beforeData: ctx.dataStartUs !== null && b.endUs <= ctx.dataStartUs,
stillConverting,
// Only a recent bucket can be waiting on ingest; an old one past the last event is simply empty.
arriving: dt !== null && b.startUs >= dt && !stillConverting && b.endUs > ctx.nowUs - DAY_US,
partial: b.startUs < ctx.range.startUs || (!running && b.endUs > ctx.range.endUs),
};
};
@@ -0,0 +1,329 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
decodeDef,
encodeDef,
foldBreakdown,
funnelParam,
parseFunnelDef,
parseSavedFunnelDraft,
parseStepRef,
pinnedAfter,
stepRefParam,
} from "@/utils/rum/productAnalyticsModel";
import { compareBreakdown } from "@/utils/rum/funnelCompare";
const S = [
["c", "product_viewed"],
["c", "payment_info_submitted"],
["c", "order_completed"],
];
const PAY = "context_payment_method";
const UPI = { field: PAY, op: "in", values: ["upi"] };
describe("attribute breakdown in the link", () => {
it("round-trips an automatic and a pinned attribute breakdown", () => {
const auto = parseFunnelDef({ s: S, b: PAY });
expect(auto?.breakdown).toEqual({ f: PAY });
expect(funnelParam(auto!)).toEqual({
s: S,
u: "sessions",
w: "session",
b: PAY,
});
const pinned = parseFunnelDef(decodeDef(encodeDef({ s: S, b: PAY, bs: 2 })));
expect(pinned?.breakdown).toEqual({ f: PAY, s: 2 });
expect(funnelParam(pinned!)).toEqual({ s: S, u: "sessions", w: "session", b: PAY, bs: 2 });
expect(parseFunnelDef(funnelParam(pinned!))).toEqual(pinned);
});
it("keeps a built-in breakdown's wire form byte-identical", () => {
const def = parseFunnelDef({ s: S, u: "users", w: "1d", b: "browser" });
expect(def?.breakdown).toBe("browser");
expect(JSON.stringify(funnelParam(def!))).toBe(
'{"s":[["c","product_viewed"],["c","payment_info_submitted"],["c","order_completed"]],"u":"users","w":"1d","b":"browser"}',
);
});
it.each([
["a session field", { b: "usr_email" }],
["a capitalised name", { b: "Payment" }],
["an internal column", { b: "_timestamp" }],
["a non-string", { b: 7 }],
["bs without b", { bs: 1 }],
["bs with a built-in", { b: "browser", bs: 1 }],
["bs of 0", { b: PAY, bs: 0 }],
["bs past the last step", { b: PAY, bs: 4 }],
["a fractional bs", { b: PAY, bs: 1.5 }],
["a text bs", { b: PAY, bs: "2" }],
])("rejects %s", (_label, patch) => {
expect(parseFunnelDef({ s: S, ...patch })).toBeNull();
});
it("reads env and version as dimensions, not columns", () => {
expect(parseFunnelDef({ s: S, b: "env" })?.breakdown).toBe("env");
expect(parseFunnelDef({ s: S, b: "version" })?.breakdown).toBe("version");
});
});
describe("step property conditions (pc) in the link", () => {
it.each(["p", "c", "r"])("parses pc on a %s step and writes it back", (kind) => {
const raw = [kind, "k", { pc: [UPI] }];
const step = parseStepRef(raw);
expect(step).toEqual({ kind, key: "k", f: { pc: [UPI] } });
expect(stepRefParam(step!)).toEqual(raw);
});
it("parses pc on a named-event step and keeps not", () => {
const id = "2kY9pF34Qy6nB3Wwd25rq4f5zr3";
const rule = { field: "context_plan", op: "regex", values: ["^pro"], not: true };
expect(parseStepRef(["e", id, { pc: [rule] }])).toEqual({
kind: "e",
key: id,
f: { pc: [rule] },
});
});
it("keeps pc beside the other options on a page step", () => {
const raw = ["p", "/pay", { min: 1000, fr: 1, pc: [UPI] }];
const step = parseStepRef(raw);
expect(step?.f).toEqual({ minMs: 1000, frustrated: true, pc: [UPI] });
expect(stepRefParam(step!)).toEqual(raw);
const def = parseFunnelDef({ s: [S[0], raw] });
expect(parseFunnelDef(funnelParam(def!))).toEqual(def);
});
it.each([
["other options on a click step", ["c", "k", { pc: [UPI], min: 1 }]],
["empty options on a click step", ["c", "k", {}]],
["an empty list", ["c", "k", { pc: [] }]],
["a non-list", ["c", "k", { pc: UPI }]],
["a session field", ["c", "k", { pc: [{ ...UPI, field: "geo_info_country" }] }]],
["env", ["p", "/a", { pc: [{ ...UPI, field: "env" }] }]],
["an unknown rule key", ["c", "k", { pc: [{ ...UPI, x: 1 }] }]],
["an unknown op", ["c", "k", { pc: [{ ...UPI, op: "like" }] }]],
["no values", ["c", "k", { pc: [{ ...UPI, values: [] }] }]],
["an empty value", ["c", "k", { pc: [{ ...UPI, values: [""] }] }]],
["a bad pattern", ["c", "k", { pc: [{ ...UPI, op: "regex", values: ["(a"] }] }]],
["11 rules", ["c", "k", { pc: Array(11).fill(UPI) }]],
])("rejects %s", (_label, raw) => {
expect(parseStepRef(raw)).toBeNull();
expect(parseFunnelDef({ s: [S[0], raw] })).toBeNull();
});
it("refuses pc on an exclusion step, which the exclusion SQL would ignore", () => {
const x = (step: unknown[]) => ({ s: S, x: [{ s: step, a: 1, b: 3 }] });
expect(parseFunnelDef(x(["c", "Buy", { pc: [UPI] }]))).toBeNull();
expect(parseFunnelDef(x(["p", "/z", { min: 1, pc: [UPI] }]))).toBeNull();
expect(parseFunnelDef(x(["p", "/z"]))?.x).toHaveLength(1);
});
it("maps a pinned step through a step change", () => {
const b = { f: PAY, s: 2 };
expect(pinnedAfter(b, (k) => k + 1)).toEqual({ f: PAY, s: 3 });
expect(pinnedAfter(b, () => null)).toEqual({ f: PAY });
expect(pinnedAfter({ f: PAY }, () => null)).toEqual({ f: PAY });
expect(pinnedAfter("browser", () => null)).toBe("browser");
});
it("accepts ten rules", () => {
expect(parseStepRef(["c", "k", { pc: Array(10).fill(UPI) }])?.f?.pc).toHaveLength(10);
});
});
const HITS = [
{ dim: "card", c1: 10, c2: 8, c3: 6, dc1: 0, dc2: 8, dc3: 6, mt1: 3000, mt2: 1500 },
{ dim: "upi", c1: 5, c2: 4, c3: 1, dc1: 0, dc2: 4, dc3: 1, mt1: 2000, mt2: 900 },
{ dim: null, c1: 20, c2: 4, c3: 2, dc1: 0, dc2: 0, dc3: 0, mt1: null, mt2: 700 },
];
describe("foldBreakdown for an attribute", () => {
it("starts at the first step where half the units carry a value", () => {
const { rows, total, start } = foldBreakdown(HITS, 3, 5, { f: PAY });
expect(start).toEqual({
step: 2,
pinned: false,
anyStep: false,
covered: 12,
reached: 16,
auto: 2,
});
expect(rows.map((r) => [r.value, r.counts, r.conversion, r.medianMs])).toEqual([
["card", [8, 8, 6], 0.75, 1500],
["upi", [4, 4, 1], 0.25, 900],
[null, [4, 4, 2], 0.5, 700],
[null, [19, 0, 0], 0, null],
]);
expect(rows[2]).toMatchObject({ noValue: true, leftBefore: false, notSet: false });
expect(rows[3]).toMatchObject({ noValue: false, leftBefore: true, notSet: false });
expect(total).toEqual([35, 16, 9]);
});
it("falls back to step 1 from any step when no step reaches half", () => {
const hits = [
{ dim: "card", c1: 10, c2: 5, c3: 2, dc1: 1, dc2: 1, dc3: 0 },
{ dim: null, c1: 30, c2: 10, c3: 3, dc1: 0, dc2: 0, dc3: 0 },
];
const { rows, total, start } = foldBreakdown(hits, 3, 5, { f: PAY });
expect(start).toEqual({
step: 1,
pinned: false,
anyStep: true,
covered: 10,
reached: 40,
auto: null,
});
expect(rows.map((r) => [r.value, r.counts])).toEqual([
["card", [10, 5, 2]],
[null, [30, 10, 3]],
]);
expect(rows[1]).toMatchObject({ notSet: true, noValue: false, leftBefore: false });
expect(rows[0].medianMs).toBeNull();
expect(total).toEqual([40, 15, 5]);
});
it("uses a pinned step even when few units carry the value there", () => {
const { rows, total, start } = foldBreakdown(HITS, 3, 5, { f: PAY, s: 3 });
expect(start).toEqual({
step: 3,
pinned: true,
anyStep: false,
covered: 7,
reached: 9,
auto: 2,
});
expect(rows.map((r) => [r.value, r.counts, r.medianMs])).toEqual([
["card", [6, 6, 6], null],
["upi", [1, 1, 1], null],
[null, [2, 2, 2], null],
[null, [26, 7, 0], null],
]);
expect(total).toEqual([35, 16, 9]);
});
it("reads a pin past the last step as automatic, as the SQL does", () => {
const { rows, total, start } = foldBreakdown(HITS, 3, 5, { f: PAY, s: 4 });
expect(start).toMatchObject({ step: 2, pinned: false, anyStep: false, auto: 2 });
expect(total).toEqual([35, 16, 9]);
expect(rows.every((r) => r.counts.every((c) => Number.isFinite(c)))).toBe(true);
});
it("puts a value nobody carried to step k in Left before, not in its own row", () => {
const hits = [...HITS, { dim: "cod", c1: 3, c2: 0, c3: 0, dc1: 0, dc2: 0, dc3: 0 }];
const { rows, total } = foldBreakdown(hits, 3, 5, { f: PAY });
expect(rows.map((r) => r.value)).toEqual(["card", "upi", null, null]);
expect(rows[3].counts).toEqual([22, 0, 0]);
expect(total).toEqual([38, 16, 9]);
});
it("ranks values at step k and folds the rest into Other apart from no value", () => {
const named = ["a", "b", "c", "d", "e", "f"].map((dim, i) => ({
dim,
c1: 10 + i,
c2: 10 - i,
c3: 1,
dc1: 0,
dc2: 10 - i,
dc3: 1,
}));
const { rows } = foldBreakdown([...named, HITS[2]], 3, 5, { f: PAY });
expect(rows.map((r) => r.value)).toEqual(["a", "b", "c", "d", "e", null, null, null]);
expect(rows[5]).toMatchObject({ other: true, counts: [5, 5, 1] });
expect(rows[6]).toMatchObject({ noValue: true });
expect(rows[7]).toMatchObject({ leftBefore: true });
});
it("leaves a built-in breakdown as before, with each value's median from step 1", () => {
const { rows, start } = foldBreakdown(HITS, 3);
expect(start).toBeNull();
expect(rows.map((r) => [r.value, r.counts, r.notSet, r.medianMs])).toEqual([
["card", [10, 8, 6], false, 3000],
["upi", [5, 4, 1], false, 2000],
[null, [20, 4, 2], true, null],
]);
});
it("compares from step k and gives Left before no previous side", () => {
const cur = foldBreakdown(HITS, 3, 5, { f: PAY });
const prev = [
{ dim: "card", c1: 20, c2: 10, c3: 5 },
{ dim: null, c1: 9, c2: 3, c3: 3 },
];
const { rows, total } = compareBreakdown(cur, HITS, prev, 3);
expect(rows[0].prev).toMatchObject({ first: 10, last: 5, conversion: 0.5 });
expect(rows[1].prev).toBeNull();
expect(rows[2].prev).toMatchObject({ first: 3, last: 3, conversion: 1 });
expect(rows[3].prev).toBeNull();
// The total compares conversion from step k too, as the table's Total row reads it.
expect(total).toMatchObject({ first: 13, last: 8 });
expect(total?.change?.pts).toBeCloseTo((9 / 16 - 8 / 13) * 100, 6);
});
it("decides attribute mode from the start step, so unset previous units never join Other", () => {
const named = ["a", "b", "c", "d", "e", "f"].map((dim, i) => ({
dim,
c1: 10 - i,
c2: 5,
c3: 2,
dc1: 10 - i,
dc2: 5,
dc3: 2,
}));
const cur = foldBreakdown(named, 3, 5, { f: PAY });
expect(cur.start).toMatchObject({ step: 1, anyStep: false });
expect(cur.rows.some((r) => r.noValue || r.leftBefore)).toBe(false);
const prev = [
{ dim: "f", c1: 6, c2: 3, c3: 1 },
{ dim: null, c1: 8, c2: 4, c3: 4 },
];
const { rows } = compareBreakdown(cur, named, prev, 3);
const other = rows.find((r) => r.other && !r.prevOnly)!;
expect(other.prev).toMatchObject({ first: 6, last: 1 });
const unset = rows[rows.length - 1];
expect(unset).toMatchObject({ prevOnly: true, notSet: true });
expect(unset.prev).toMatchObject({ first: 8, last: 4 });
});
it("shows a previous value with no units at step k now as previous-only, read from step k", () => {
const cod = { dim: "cod", c1: 3, c2: 0, c3: 0, dc1: 0, dc2: 0, dc3: 0 };
const hits = [...HITS, cod];
const cur = foldBreakdown(hits, 3, 5, { f: PAY });
expect(cur.rows.some((r) => r.other)).toBe(false);
const prev = [{ dim: "cod", c1: 9, c2: 4, c3: 2 }];
const { rows } = compareBreakdown(cur, hits, prev, 3);
const last = rows[rows.length - 1];
expect(last).toMatchObject({ value: "cod", prevOnly: true });
expect(last.prev).toMatchObject({ first: 4, last: 2, conversion: 0.5 });
});
});
describe("saving a funnel with an attribute breakdown or a step property condition", () => {
it("keeps b, bs and pc in the saved definition", () => {
const def = parseFunnelDef({
s: [S[0], [...S[1], { pc: [{ field: PAY, op: "in", values: ["upi"] }] }], S[2]],
b: PAY,
bs: 2,
})!;
const body = parseSavedFunnelDraft({ name: "Pay", def, sql: "SELECT 1" });
expect(body?.def).toMatchObject({
b: PAY,
bs: 2,
s: [S[0], [...S[1], { pc: [{ field: PAY, op: "in", values: ["upi"] }] }], S[2]],
});
expect(parseFunnelDef(body!.def)).toEqual(def);
});
});
@@ -798,7 +798,7 @@ describe("URL codec and validators (D-45, AC-14, AC-20)", () => {
["unknown window", { s: [["p", "/"]], u: "users", w: "2y" }],
["kind x", { s: [["x", "/"]], u: "sessions", w: "session" }],
["1,000 steps", { s: Array(1000).fill(["p", "/"]), u: "sessions", w: "session" }],
["unknown dimension", { s: [["p", "/"]], u: "sessions", w: "session", b: "email" }],
["unknown dimension", { s: [["p", "/"]], u: "sessions", w: "session", b: "usr_email" }],
["long key", { s: [["p", "x".repeat(1025)]], u: "sessions", w: "session" }],
["no steps", { s: [], u: "sessions", w: "session" }],
])("rejects %s", (_label, raw) => {
+238 -63
View File
@@ -24,6 +24,9 @@ import {
DIMENSIONS,
IDENTITY_FIELDS,
MAX_FUNNEL_STEPS,
isAttributeBreakdown,
type AttributeBreakdown,
type Breakdown,
type BreakdownDim,
type ExclusionStep,
type FunnelCohort,
@@ -176,6 +179,27 @@ export interface BreakdownRow {
atLeast: boolean;
other: boolean;
notSet: boolean;
/** Units that reached the start step with no value; only an attribute breakdown has this row. */
noValue: boolean;
/** Units that never reached the start step; only an attribute breakdown has this row. */
leftBefore: boolean;
/** Median ms from the start step to the last; null where the run has none. */
medianMs: number | null;
}
/** The step an attribute breakdown's rates start at, and how many units reaching it carry a value there. */
export interface BreakdownStart {
step: number;
pinned: boolean;
anyStep: boolean;
covered: number;
reached: number;
/** The step the automatic rule picks, even while another is pinned; null when no step reaches half. */
auto: number | null;
}
export interface BreakdownFold {
rows: BreakdownRow[];
total: number[];
start: BreakdownStart | null;
}
export type PathNodeKind = StepKind | "other" | "exit" | "start";
export interface PathNode {
@@ -317,8 +341,9 @@ export const EXACT_ROW_LIMIT = 1000000;
export const CHANGE_THRESHOLD = 0.5;
const DOMINANT_SHARE = 0.2;
const FULL_COVERAGE = 0.5;
const BREAKDOWN_COVERAGE = 0.5;
const DATA_TEST_SHARE = 0.2;
const BREAKDOWN_TOP = 5;
export const BREAKDOWN_TOP = 5;
const BREAKDOWN_LIMIT = 1000;
const PATH_BRANCHES = 5;
const PATH_ROW_LIMIT = 5000;
@@ -350,6 +375,7 @@ const MAX_EXCLUSIONS = 5;
export const MAX_FUNNEL_FILTERS = 10;
export const MAX_FILTER_VALUES = 20;
export const MAX_VIEW_CONDS = 5;
export const MAX_STEP_PROP_CONDS = 10;
/** Session fields Filter all steps can use; env and version stay page-wide. */
export const FILTER_FIELDS: readonly SessionField[] = [
"geo_info_country",
@@ -824,9 +850,9 @@ export function decodeDef(param: string | undefined): unknown {
}
}
const STEP_OPTION_KEYS = ["min", "fr", "er", "vc"];
const STEP_OPTION_KEYS = ["min", "fr", "er", "vc", "pc"];
const EXCLUSION_KEYS = ["s", "a", "b", "w"];
const FUNNEL_KEYS = ["s", "u", "w", "b", "x", "f"];
const FUNNEL_KEYS = ["s", "u", "w", "b", "bs", "x", "f"];
const FILTER_KEYS = ["field", "op", "values", "not"];
const PARAM_COND_KEYS = ["t", "key", "op", "values"];
const REF_COND_KEYS = ["t", "op", "values"];
@@ -861,18 +887,31 @@ const parseViewConds = (raw: unknown): ViewCond[] | null => {
return conds.every((c) => c !== null) ? (conds as ViewCond[]) : null;
};
// Mirrors the server: an attribute column, never a built-in session field, like an attribute breakdown.
const parsePropConds = (raw: unknown): FunnelFilter[] | null => {
if (!Array.isArray(raw) || !raw.length || raw.length > MAX_STEP_PROP_CONDS) return null;
const conds = raw.map(parseFunnelFilter);
return conds.every((c) => c !== null && isFilterColumn(c.field))
? (conds as FunnelFilter[])
: null;
};
// An unknown option must fail the whole definition, so an older build never drops a filter it cannot apply.
const parseStepFilters = (raw: unknown): StepFilters | null => {
if (!isRecord(raw) || !onlyKeys(raw, STEP_OPTION_KEYS)) return null;
const parseStepFilters = (raw: unknown, kind: StepKind): StepFilters | null => {
if (!isRecord(raw) || !onlyKeys(raw, kind === "p" ? STEP_OPTION_KEYS : ["pc"])) return null;
if (kind !== "p" && raw.pc === undefined) return null;
if (raw.min !== undefined && !isMs(raw.min)) return null;
if ((raw.fr !== undefined && raw.fr !== 1) || (raw.er !== undefined && raw.er !== 1)) return null;
const vc = raw.vc === undefined ? undefined : parseViewConds(raw.vc);
if (vc === null) return null;
const pc = raw.pc === undefined ? undefined : parsePropConds(raw.pc);
if (pc === null) return null;
return {
...(raw.min !== undefined ? { minMs: raw.min as number } : {}),
...(raw.fr === 1 ? { frustrated: true as const } : {}),
...(raw.er === 1 ? { errored: true as const } : {}),
...(vc ? { vc } : {}),
...(pc ? { pc } : {}),
};
};
@@ -904,7 +943,7 @@ export function parseStepRef(raw: unknown): StepRef | null {
if (kind !== "p" && kind !== "c" && kind !== "e" && kind !== "r") return null;
if (kind === "e" ? !isEntityId(key) : !isKey(key)) return null;
if (raw.length === 2) return { kind: kind as StepKind, key };
const f = kind === "p" ? parseStepFilters(opts) : null;
const f = parseStepFilters(opts, kind);
return f ? { kind, key, f } : null;
}
@@ -913,9 +952,16 @@ const viewCondParam = (c: ViewCond): Record<string, unknown> =>
? { t: "param", key: c.key, op: c.op, values: [...c.values] }
: { t: "ref", op: c.op, values: [...c.values] };
const filterParam = (r: FunnelFilter): Record<string, unknown> => ({
field: r.field,
op: r.op,
values: [...r.values],
...(r.not ? { not: true } : {}),
});
export function stepRefParam(step: StepRef): unknown[] {
if (!step.f) return [step.kind, step.key];
const { minMs, frustrated, errored, vc } = step.f;
const { minMs, frustrated, errored, vc, pc } = step.f;
return [
step.kind,
step.key,
@@ -924,6 +970,7 @@ export function stepRefParam(step: StepRef): unknown[] {
...(frustrated ? { fr: 1 } : {}),
...(errored ? { er: 1 } : {}),
...(vc?.length ? { vc: vc.map(viewCondParam) } : {}),
...(pc?.length ? { pc: pc.map(filterParam) } : {}),
},
];
}
@@ -963,6 +1010,18 @@ const parseExclusion = (raw: unknown, steps: StepRef[]): ExclusionStep | null =>
return x && !exclusionProblem(steps, x) ? x : null;
};
// A dimension name wins over a column of the same name, as on the server; undefined means invalid.
const parseBreakdown = (b: unknown, bs: unknown, steps: number): Breakdown | null | undefined => {
if (b === undefined || b === null) return bs === undefined ? null : undefined;
if (typeof b !== "string") return undefined;
if (Object.prototype.hasOwnProperty.call(DIMENSIONS, b))
return bs === undefined ? (b as BreakdownDim) : undefined;
if (!isFilterColumn(b)) return undefined;
if (bs === undefined) return { f: b };
const ok = typeof bs === "number" && Number.isInteger(bs) && bs >= 1 && bs <= steps;
return ok ? { f: b, s: bs } : undefined;
};
// An unknown key must fail the whole definition, so an older build never drops a setting it cannot apply.
export function parseFunnelDef(raw: unknown): FunnelDef | null {
if (!isRecord(raw) || !Array.isArray(raw.s) || !onlyKeys(raw, FUNNEL_KEYS)) return null;
@@ -974,8 +1033,8 @@ export function parseFunnelDef(raw: unknown): FunnelDef | null {
if (!unit) return null;
const w = raw.w === undefined ? "session" : raw.w;
if (!WINDOWS.includes(w as FunnelWindow)) return null;
const b = raw.b === undefined || raw.b === null ? null : raw.b;
if (b !== null && !Object.prototype.hasOwnProperty.call(DIMENSIONS, b as string)) return null;
const b = parseBreakdown(raw.b, raw.bs, steps.length);
if (b === undefined) return null;
const xs = raw.x === undefined ? [] : raw.x;
if (!Array.isArray(xs) || xs.length > MAX_EXCLUSIONS) return null;
const x = xs.map((e) => parseExclusion(e, steps as StepRef[]));
@@ -988,32 +1047,39 @@ export function parseFunnelDef(raw: unknown): FunnelDef | null {
steps: steps as StepRef[],
unit,
window: unit === "sessions" ? "session" : (w as FunnelWindow),
breakdown: b as BreakdownDim | null,
breakdown: b,
x: x as ExclusionStep[],
...(f.length ? { f: f as FunnelFilter[] } : {}),
};
}
/** The breakdown with its pinned step at its new number; a pinned step that is gone falls back to automatic. */
export function pinnedAfter(
b: Breakdown | null,
pos: (k: number) => number | null,
): Breakdown | null {
if (!isAttributeBreakdown(b) || b.s === undefined) return b;
const s = pos(b.s);
return s === null ? { f: b.f } : { f: b.f, s };
}
export function funnelParam(def: FunnelDef): Record<string, unknown> {
const out: Record<string, unknown> = {
s: def.steps.map(stepRefParam),
u: def.unit,
w: def.window,
};
if (def.breakdown) out.b = def.breakdown;
if (isAttributeBreakdown(def.breakdown)) {
out.b = def.breakdown.f;
if (def.breakdown.s !== undefined) out.bs = def.breakdown.s;
} else if (def.breakdown) out.b = def.breakdown;
if (def.x?.length)
out.x = def.x.map((e) => ({
s: stepRefParam(e.step),
a: e.a,
...(e.b !== undefined ? { b: e.b } : { w: e.withinMs }),
}));
if (def.f?.length)
out.f = def.f.map((r) => ({
field: r.field,
op: r.op,
values: [...r.values],
...(r.not ? { not: true } : {}),
}));
if (def.f?.length) out.f = def.f.map(filterParam);
return out;
}
@@ -1390,6 +1456,82 @@ export function clickNameHint(rows: { key: string; clicks: number }[]): boolean
const nullableNum = (v: unknown): number | null =>
v === null || v === undefined || v === "" ? null : num(v);
const sumOf = (rows: number[][], steps: number): number[] =>
rows.reduce<number[]>((acc, c) => acc.map((v, i) => v + c[i]), Array(steps).fill(0));
// A unit that reached step k passed every step before it, so the earlier columns read its step-k count.
const countsFrom = (c: number[], k: number): number[] =>
c.map((v, i) => (i < k - 1 ? c[k - 1] : v));
const unitsBefore = (c: number[], k: number): number[] =>
c.map((v, i) => (i < k - 1 ? v - c[k - 1] : 0));
// The first step where at least half the units reaching it carry a value on that step's row.
const breakdownStart = (
hits: Record<string, unknown>[],
steps: number,
attribute: AttributeBreakdown,
): BreakdownStart => {
const reached = sumOf(
hits.map((h) => hitCounts(h, steps)),
steps,
);
const covered = Array.from({ length: steps }, (_, i) =>
hits.reduce((sum, h) => sum + num(h[`dc${i + 1}`]), 0),
);
// Coverage reads each step's own rows whatever the pin, so the automatic step is known either way.
const i = reached.findIndex((c, j) => c > 0 && covered[j] / c >= BREAKDOWN_COVERAGE);
const auto = i >= 0 ? i + 1 : null;
const at = (step: number, pinned: boolean): BreakdownStart => ({
step,
pinned,
anyStep: false,
covered: covered[step - 1],
reached: reached[step - 1],
auto,
});
// A pin past the last step reads as automatic, as the SQL does.
if (attribute.s !== undefined && attribute.s >= 1 && attribute.s <= steps)
return at(attribute.s, true);
if (auto !== null) return at(auto, false);
const named = hits.filter(isNamedDim).reduce((sum, h) => sum + num(h.c1), 0);
return {
step: 1,
pinned: false,
anyStep: true,
covered: named,
reached: reached[0] ?? 0,
auto: null,
};
};
// Units with no value split by whether they reached the start step; from any step, they stay "Not set".
const unsetRows = (
hits: Record<string, unknown>[],
steps: number,
start: BreakdownStart,
row: (c: number[], over: Partial<BreakdownRow>) => BreakdownRow,
median: (h: Record<string, unknown>) => number | null,
): BreakdownRow[] => {
const k = start.anyStep ? 1 : start.step;
const unset = hits.filter((h) => !isNamedDim(h));
const out: BreakdownRow[] = [];
const reachedK = sumOf(
unset.map((h) => countsFrom(hitCounts(h, steps), k)),
steps,
);
if (reachedK[k - 1] > 0 || (start.anyStep && unset.length)) {
const one = unset.length === 1 ? median(unset[0]) : null;
out.push(row(reachedK, start.anyStep ? { notSet: true } : { noValue: true, medianMs: one }));
}
const before = sumOf(
hits.map((h) => unitsBefore(hitCounts(h, steps), k)),
steps,
);
if (before.some((v) => v > 0)) out.push(row(before, { leftBefore: true, conversion: 0 }));
return out;
};
export function toFunnelResult(hit: Record<string, number>, def: FunnelDef): FunnelResult {
const units: number[] = [];
def.steps.forEach((_, i) => {
@@ -1427,51 +1569,81 @@ export function exclusionRemoved(
});
}
/** A breakdown hit's per-step counts, c1..cN. */
export function hitCounts(h: Record<string, unknown>, steps: number): number[] {
return Array.from({ length: steps }, (_, i) => num(h[`c${i + 1}`]));
}
/** Whether a breakdown hit carries a value; an empty dim is "(not set)". */
export function isNamedDim(h: Record<string, unknown>): boolean {
return h.dim !== null && h.dim !== undefined && h.dim !== "";
}
export function foldBreakdown(
hits: Record<string, unknown>[],
steps: number,
top: number = BREAKDOWN_TOP,
): { rows: BreakdownRow[]; total: number[] } {
const counts = (h: Record<string, unknown>) =>
Array.from({ length: steps }, (_, i) => num(h[`c${i + 1}`]));
const sorted = [...hits].sort((a, b) => num(b.c1) - num(a.c1));
const named = sorted.filter((h) => h.dim !== null && h.dim !== undefined && h.dim !== "");
attribute: AttributeBreakdown | null = null,
): BreakdownFold {
const start = attribute ? breakdownStart(hits, steps, attribute) : null;
const k = start && !start.anyStep ? start.step : 1;
const counts = (h: Record<string, unknown>) => countsFrom(hitCounts(h, steps), k);
const sorted = [...hits].sort((a, b) => num(b[`c${k}`]) - num(a[`c${k}`]));
// A value whose units all left before step k only adds to "Left before step k".
const named = sorted.filter((h) => isNamedDim(h) && (!start || num(h[`c${k}`]) > 0));
const head = named.slice(0, top);
const rest = sorted.filter((h) => !head.includes(h));
const conversion = (c: number[]) => (c[0] > 0 ? c[c.length - 1] / c[0] : 0);
const rows: BreakdownRow[] = head.map((h) => {
const c = counts(h);
return {
value: String(h.dim),
label: String(h.dim),
counts: c,
conversion: conversion(c),
atLeast: false,
other: false,
notSet: false,
};
const rest = start ? named.slice(top) : sorted.filter((h) => !head.includes(h));
const conversion = (c: number[]) => (c[k - 1] > 0 ? c[c.length - 1] / c[k - 1] : 0);
const median = (h: Record<string, unknown>) => (k < steps ? nullableNum(h[`mt${k}`]) : null);
const row = (c: number[], over: Partial<BreakdownRow>): BreakdownRow => ({
value: null,
label: "",
counts: c,
conversion: conversion(c),
atLeast: false,
other: false,
notSet: false,
noValue: false,
leftBefore: false,
medianMs: null,
...over,
});
if (rest.length) {
const c = rest.reduce<number[]>(
(acc, h) => acc.map((v, i) => v + counts(h)[i]),
Array(steps).fill(0),
);
const notSet = rest.every((h) => h.dim === null || h.dim === undefined || h.dim === "");
rows.push({
value: null,
label: "",
counts: c,
conversion: conversion(c),
atLeast: hits.length >= BREAKDOWN_LIMIT,
other: !notSet,
notSet,
});
}
const total = rows.reduce<number[]>(
(acc, r) => acc.map((v, i) => v + r.counts[i]),
Array(steps).fill(0),
const rows: BreakdownRow[] = head.map((h) =>
row(counts(h), { value: String(h.dim), label: String(h.dim), medianMs: median(h) }),
);
return { rows, total };
if (rest.length) {
const notSet = rest.every((h) => !isNamedDim(h));
rows.push(
row(sumOf(rest.map(counts), steps), {
atLeast: hits.length >= BREAKDOWN_LIMIT,
other: !notSet,
notSet,
}),
);
}
if (start) rows.push(...unsetRows(hits, steps, start, row, median));
const total = sumOf(
rows.map((r) => r.counts),
steps,
);
return { rows, total, start };
}
/** One run's funnel numbers; a breakdown run sums its per-value rows, so both periods read the same way. */
export function funnelResultOf(
rows: Record<string, unknown>[],
def: FunnelDef,
): FunnelResult | null {
if (def.breakdown) {
const hit: Record<string, number> = {};
foldBreakdown(rows, def.steps.length).total.forEach((c, i) => {
hit[`c${i + 1}`] = c;
hit[`seen${i + 1}`] = 1;
});
return toFunnelResult(hit, def);
}
const row = rows[0] as Record<string, number> | undefined;
return row ? toFunnelResult(row, def) : null;
}
const UNITS = [
@@ -1752,7 +1924,7 @@ export function branchPredicate(flow: PathFlow, target: BranchTarget): string {
);
}
type Period = "day" | "week" | "month";
export type Period = "day" | "week" | "month";
const periodStart = (d: Date, g: Period): Date =>
g === "day"
@@ -1764,6 +1936,13 @@ const periodStart = (d: Date, g: Period): Date =>
const nextPeriod = (d: Date, g: Period): Date =>
g === "day" ? addDays(d, 1) : g === "week" ? addWeeks(d, 1) : addMonths(d, 1);
/** Where the period starting at `startUs` ends, stepped in the zone's wall time like the boundaries. */
export const periodEndUs = (startUs: number, timezone: string, g: Period): number =>
fromZonedTime(
nextPeriod(toZonedTime(new Date(startUs / 1000), timezone), g),
timezone,
).getTime() * 1000;
// Boundaries are stepped in zoned wall time and converted back, so a DST day is 23 or 25 hours long.
const boundaries = (
startUs: number,
@@ -1799,7 +1978,7 @@ const monthBlocked = (endUs: number, dataStartUs: number | null): boolean => {
return days !== null && days < MONTH_DATA_DAYS;
};
const autoGranularity = (
export const autoGranularity = (
startUs: number,
endUs: number,
dataStartUs: number | null,
@@ -1848,11 +2027,7 @@ export function retentionPeriods(
const { out, clipped } = boundaries(startUs, endUs, timezone, g, dataStartUs);
if (out.length > MAX_PERIODS) return { tooMany: true };
if (out.length < MIN_PERIODS) return { tooShort: true };
const lastEnd =
fromZonedTime(
nextPeriod(toZonedTime(new Date(out[out.length - 1] / 1000), timezone), g),
timezone,
).getTime() * 1000;
const lastEnd = periodEndUs(out[out.length - 1], timezone, g);
return {
granularity: g,
boundariesUs: out,
@@ -16,10 +16,15 @@
import { describe, expect, it } from "vitest";
import i18n from "@/locales";
import type { TranslateFn } from "@/types/i18n";
import { buildFunnelPanel, buildTrendsPanel } from "@/utils/rum/productAnalyticsPanels";
import {
buildFunnelPanel,
buildFunnelTrendPanel,
buildTrendsPanel,
} from "@/utils/rum/productAnalyticsPanels";
import {
assertJoinFree,
funnelPanelSql,
funnelTrendPanelSql,
trendSql,
type AnalyticsScope,
} from "@/utils/rum/productAnalyticsQueries";
@@ -122,6 +127,40 @@ describe("buildFunnelPanel (AC-53)", () => {
expect(p.queries[0].fields.y[0]).toMatchObject({ alias: "y_axis_1", label: "Sessions" });
assertJoinFree(p.queries[0].query);
});
it("from Trend is a line panel of each session's entry, bucketed by histogram at the chosen interval", () => {
const three = { ...def, steps: [...def.steps, { kind: "c" as const, key: "buy" }] };
const p = buildFunnelTrendPanel(
scope,
three,
{ a: 2, b: 3 },
"week",
events,
t,
) as unknown as Panel & {
title: string;
};
expect(p.type).toBe("line");
expect(p.title).toBe("Conversion over time: Saved → buy");
expect((p as unknown as { config: Record<string, unknown> }).config).toMatchObject({
decimals: 1,
connect_nulls: false,
});
expect(p.queries[0].query).toBe(
funnelTrendPanelSql(scope, three, { a: 2, b: 3 }, "1 week", { events, sample: 1 }),
);
expect(p.queries[0].query).toContain("histogram(entry_ts, '1 week') AS x_axis_1");
expect(p.queries[0].fields.y[0]).toMatchObject({ alias: "y_axis_1", label: "Conversion (%)" });
for (const [g, interval] of [
["day", "1 day"],
["month", "1 month"],
] as const)
expect(
(buildFunnelTrendPanel(scope, three, { a: 1, b: 3 }, g, events, t) as unknown as Panel)
.queries[0].query,
).toContain(`histogram(entry_ts, '${interval}')`);
assertJoinFree(p.queries[0].query);
});
});
describe("P5 gap fixes: frozen exclusions in the panel SQL", () => {
+42 -1
View File
@@ -14,16 +14,24 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { raw, type I18nText, type TranslateFn } from "@/types/i18n";
import type { NamedEvent } from "@/utils/rum/productAnalyticsModel";
import type { NamedEvent, Period } from "@/utils/rum/productAnalyticsModel";
import {
funnelPanelSql,
funnelTrendPanelSql,
trendSql,
type AnalyticsScope,
type FunnelDef,
type FunnelPair,
type IdentitySql,
type StepRef,
} from "@/utils/rum/productAnalyticsQueries";
const PANEL_INTERVAL: Readonly<Record<Period, string>> = {
day: "1 day",
week: "1 week",
month: "1 month",
};
interface AxisField {
alias: string;
column: string;
@@ -55,6 +63,7 @@ const sqlPanel = (
sql: string,
x: AxisField,
y: AxisField[],
config: Record<string, unknown> = {},
): Record<string, unknown> => ({
version: 2,
id,
@@ -72,6 +81,7 @@ const sqlPanel = (
base_map: { type: "osm" },
map_view: { zoom: 1, lat: 0, lng: 0 },
mark_line: [],
...config,
},
queryType: "sql",
queries: [
@@ -155,3 +165,34 @@ export function buildFunnelPanel(
[axis("y_axis_1", t("rum.analytics.trends.sessions"))],
);
}
/** The Trend view's line: conversion between the pair's steps per interval of each session's entry. */
export function buildFunnelTrendPanel(
scope: AnalyticsScope,
def: FunnelDef,
pair: FunnelPair,
interval: Period,
events: NamedEvent[],
t: TranslateFn,
deletedNames: Readonly<Record<string, string>> = {},
): Record<string, unknown> {
const sql = funnelTrendPanelSql(scope, def, pair, PANEL_INTERVAL[interval], {
events,
sample: 1,
});
const label = (i: number) =>
def.steps[i - 1] ? stepLabel(def.steps[i - 1], events, deletedNames) : "";
return sqlPanel(
"rum-analytics-funnel-trend",
"line",
t("rum.analytics.dashboard.funnelTrendTitle", {
first: raw(label(pair.a)),
last: raw(label(pair.b)),
}),
sql,
axis("x_axis_1", t("rum.analytics.trends.time")),
[axis("y_axis_1", t("rum.analytics.funnel.trendAxis"))],
// The SQL rounds to one decimal, and an interval with no entrants is a gap, not a slope.
{ decimals: 1, connect_nulls: false },
);
}
@@ -0,0 +1,333 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
assertJoinFree,
cohortSessionsSql,
funnelAlertSql,
funnelSql,
funnelTrendSql,
stepFieldSampleSql,
whyEnteredSql,
type AnalyticsScope,
type FunnelDef,
type FunnelFilter,
type StepRef,
} from "@/utils/rum/productAnalyticsQueries";
const PAY = "context_payment_method";
const s: AnalyticsScope = {
app: "web",
env: [],
version: [],
schema: {
action_target_name: true,
usr_email: true,
user_agent_user_agent_family: true,
view_name: true,
[PAY]: true,
context_plan: true,
},
};
const id = { field: "usr_email" as const, excluded: [] };
const opts = { events: [], sample: 1 as const };
const c = (key: string, f?: StepRef["f"]): StepRef => ({ kind: "c", key, ...(f ? { f } : {}) });
const steps = [c("product_viewed"), c("payment_info_submitted"), c("order_completed")];
const def = (patch: Partial<FunnelDef> = {}): FunnelDef => ({
steps,
unit: "sessions",
window: "session",
breakdown: null,
...patch,
});
const UPI: FunnelFilter = { field: PAY, op: "in", values: ["upi"] };
const cte = (sql: string, name: string): string => {
const at = sql.indexOf(`${name} AS (`);
const end = sql.indexOf("\n", at);
return sql.slice(at, end < 0 ? undefined : end);
};
const tail = (sql: string): string => sql.slice(sql.lastIndexOf("\n") + 1);
const DIM_COL = `MIN(NULLIF(CAST("${PAY}" AS VARCHAR), '')) AS dim`;
const ANY = "m1 = 1 OR m2 = 1 OR m3 = 1";
const AUTO = `first_value(CASE WHEN ${ANY} THEN dim END ORDER BY CASE WHEN dim IS NOT NULL AND (${ANY}) THEN 0 ELSE 1 END, t) AS dim`;
const PINNED_2 =
"first_value(CASE WHEN m2 = 1 THEN dim END ORDER BY CASE WHEN m2 = 1 AND dim IS NOT NULL THEN 0 ELSE 1 END, t) AS dim";
const H = [1, 2, 3].map(
(i) => `MAX(CASE WHEN m${i} = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h${i}`,
);
const DC = [1, 2, 3].map((i) => `SUM(CASE WHEN r${i} = 1 THEN h${i} ELSE 0 END) AS dc${i}`);
describe("attribute breakdown SQL", () => {
it("reads the first value on any step's row, with coverage and median columns", () => {
const sql = funnelSql(s, null, def({ breakdown: { f: PAY } }), opts);
assertJoinFree(sql);
expect(cte(sql, "x00")).toContain(DIM_COL);
expect(cte(sql, "s")).toContain(
`${AUTO}, MIN(CASE WHEN m1 = 1 THEN t END) AS t1, ${H.join(", ")}`,
);
expect(cte(sql, "ps")).toContain("dim, t1, h1, h2, h3");
expect(tail(sql)).toBe(
`SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, ${DC.join(", ")}, approx_percentile_cont(d3, 0.5) AS mt1, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM ps GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000`,
);
expect(sql).toMatchSnapshot();
});
it("reads a pinned step's own rows only, with that step's median", () => {
const sql = funnelSql(s, null, def({ breakdown: { f: PAY, s: 2 } }), opts);
expect(cte(sql, "s")).toContain(PINNED_2);
expect(tail(sql)).toContain(
`${DC.join(", ")}, approx_percentile_cont(d3 - d2, 0.5) AS mt2 FROM ps`,
);
expect(tail(sql)).not.toContain("mt1");
const last = funnelSql(s, null, def({ breakdown: { f: PAY, s: 3 } }), opts);
expect(tail(last)).not.toContain("approx_percentile_cont");
expect(sql).toMatchSnapshot();
});
it("takes a user's first non-empty value and any session's coverage in Users mode", () => {
const sql = funnelSql(s, id, def({ unit: "users", breakdown: { f: PAY } }), opts);
assertJoinFree(sql);
expect(cte(sql, "pu")).toContain(
"first_value(dim ORDER BY CASE WHEN dim IS NULL THEN 1 ELSE 0 END, t1) AS dim, MAX(h1) AS h1, MAX(h2) AS h2, MAX(h3) AS h3",
);
expect(tail(sql)).toContain(`${DC.join(", ")}, approx_percentile_cont(d3, 0.5) AS mt1`);
expect(tail(sql)).toContain("FROM pu WHERE anon = 0 GROUP BY dim");
expect(sql).toMatchSnapshot();
});
it("carries coverage through a windowed Users run", () => {
const sql = funnelSql(
s,
id,
def({ unit: "users", window: "1d", breakdown: { f: PAY, s: 2 } }),
opts,
);
expect(cte(sql, "su")).toContain(`${PINNED_2}, ${H.join(", ")}`);
expect(cte(sql, "pu")).toContain("dim, h1, h2, h3");
expect(sql).toMatchSnapshot();
});
it("reads a pin past the last step as automatic", () => {
const sql = funnelSql(s, null, def({ breakdown: { f: PAY, s: 4 } }), opts);
expect(cte(sql, "s")).toContain(AUTO);
expect(sql).not.toContain("m4");
});
it("drops the breakdown when the stream lacks the column", () => {
const sql = funnelSql(s, null, def({ breakdown: { f: "context_missing" } }), opts);
expect(sql).not.toContain("dim");
});
it("buckets a trend by the attribute value", () => {
const sql = funnelTrendSql(
s,
null,
def({ breakdown: { f: PAY } }),
[0, 1, 2],
{ a: 1, b: 3 },
opts,
);
expect(cte(sql, "s")).toContain(AUTO);
expect(cte(sql, "tb")).toContain("SELECT dim,");
});
});
describe("step property conditions (pc) SQL", () => {
const MATCH = `("${PAY}" IS NOT NULL AND CAST("${PAY}" AS VARCHAR) IN ('upi'))`;
it("flags a later step's own rows and tests the flag inside that step", () => {
const d = def({ steps: [steps[0], c("payment_info_submitted", { pc: [UPI] }), steps[2]] });
const sql = funnelSql(s, null, d, opts);
assertJoinFree(sql);
expect(cte(sql, "x00")).toContain(`MAX(CASE WHEN ${MATCH} THEN 1 ELSE 0 END) AS pc2_1`);
expect(cte(sql, "x0")).toContain(", pc2_1");
expect(cte(sql, "x1")).toContain(
"k = 'payment_info_submitted' AND pc2_1 = 1 THEN 1 ELSE 0 END AS m2",
);
expect(cte(sql, "x1")).not.toContain("pc2_1 = 1 THEN 1 ELSE 0 END AS m1");
expect(sql).toMatchSnapshot();
});
it("keeps step 1 to rows without the value when negated, and checks each rule", () => {
const not: FunnelFilter = { field: "context_plan", op: "contains", values: ["pro"], not: true };
const d = def({ steps: [c("product_viewed", { pc: [UPI, not] }), steps[1], steps[2]] });
const sql = funnelSql(s, null, d, opts);
expect(cte(sql, "x00")).toContain("AS pc1_1");
expect(cte(sql, "x00")).toContain(
`MAX(CASE WHEN ("context_plan" IS NOT NULL AND strpos(CAST("context_plan" AS VARCHAR), 'pro') > 0) THEN 1 ELSE 0 END) AS pc1_2`,
);
expect(cte(sql, "x1")).toContain("AND pc1_1 = 1 AND pc1_2 = 0 THEN 1 ELSE 0 END AS m1");
expect(sql).toMatchSnapshot();
});
it("matches nothing on a column the stream lacks", () => {
const d = def({
steps: [
steps[0],
c("payment_info_submitted", { pc: [{ ...UPI, field: "context_missing" }] }),
steps[2],
],
});
expect(cte(funnelSql(s, null, d, opts), "x00")).toContain(
"MAX(CASE WHEN (1 = 0) THEN 1 ELSE 0 END) AS pc2_1",
);
});
it("flows into the alert, the drill-ins and Why N", () => {
const d = def({ steps: [c("product_viewed", { pc: [UPI] }), steps[1], steps[2]] });
expect(funnelAlertSql(s, null, d, 50, opts)).toContain("AND pc1_1 = 1 THEN 1 ELSE 0 END AS m1");
const cohort = { funnel: d, stepIndex: 1, side: "dropped" as const };
expect(cohortSessionsSql(s, null, cohort, 0, opts)).toContain(
"AND pc1_1 = 1 THEN 1 ELSE 0 END AS m1",
);
const why = whyEnteredSql(s, d, opts);
assertJoinFree(why);
expect(cte(why, "g0")).toContain(`MAX(CASE WHEN ${MATCH} THEN 1 ELSE 0 END) AS p1`);
expect(cte(why, "g0")).toContain("GROUP BY session_id, type, CASE WHEN type = 'view'");
expect(cte(why, "g")).toContain("MAX(CASE WHEN o0 = 1 AND p1 = 1 THEN 1 ELSE 0 END) AS o1");
expect(cte(why, "g")).toContain("MAX(CASE WHEN k0 = 1 AND p1 = 1 THEN 1 ELSE 0 END) AS k1");
expect(why).toMatchSnapshot();
});
});
describe("trend ranking for an attribute breakdown", () => {
const b = def({ breakdown: { f: PAY } });
const bounds = [0, 86400000000];
const pair = { a: 1, b: 3 };
it("ranks values by units at the table's start step and keeps none without units there", () => {
const sql = funnelTrendSql(s, null, b, bounds, pair, opts, 2);
assertJoinFree(sql);
expect(cte(sql, "tb")).toContain("SUM(r1) AS c1, SUM(r2) AS c2, SUM(r1) AS ca");
expect(cte(sql, "tk")).toContain(
"SUM(c2) OVER (PARTITION BY dim) AS tot, SUM(c1) OVER (PARTITION BY dim) AS t1",
);
// Ties at step k fall back to step-1 units, as foldBreakdown's stable sort does.
expect(cte(sql, "tr")).toContain("DENSE_RANK() OVER (ORDER BY un, tot DESC, t1 DESC, dim)");
expect(tail(sql)).toContain("MAX(tot) AS tot, MAX(t1) AS t1, bk");
expect(cte(sql, "tg")).toContain("CASE WHEN un = 0 AND tot > 0 AND rk <= 5 THEN dim END");
});
it("ranks from step 1 by default, and reads a rank step the funnel lacks as step 1", () => {
const base = funnelTrendSql(s, null, b, bounds, pair, opts);
expect(cte(base, "tb")).toContain("SUM(r1) AS c1, SUM(r1) AS ca");
expect(cte(base, "tg")).toContain("CASE WHEN un = 0 AND rk <= 5 THEN dim END");
expect(funnelTrendSql(s, null, b, bounds, pair, opts, 1)).toBe(base);
expect(funnelTrendSql(s, null, b, bounds, pair, opts, 4)).toBe(base);
});
});
describe("per-step field sample", () => {
const p = (key: string): StepRef => ({ kind: "p", key });
const pageSteps = [p("/cart"), c("checkout_started"), p("/checkout/shipping")];
// The step-flag subquery: everything from its SELECT up to the _rumdata scan.
const flagScan = (sql: string) => {
const scan = sql.indexOf('FROM "_rumdata"');
return sql.slice(sql.lastIndexOf("(SELECT ", scan), scan);
};
it("ranks each step's own rows, so a row matching two steps is sampled for both", () => {
const sql = stepFieldSampleSql(s, def(), [], [PAY, "context_plan", "context_missing"], 200);
assertJoinFree(sql);
expect(sql).toContain(
"CASE WHEN (type = 'action' AND action_target_name <> '' AND regexp_replace(",
);
for (const i of [1, 2, 3])
expect(sql).toContain(
`ROW_NUMBER() OVER (PARTITION BY s${i} ORDER BY _timestamp DESC) AS r${i}`,
);
expect(sql.startsWith(`SELECT s1, s2, s3, "${PAY}", "context_plan" FROM (`)).toBe(true);
expect(sql).not.toContain("context_missing");
expect(
sql.endsWith(
"WHERE (s1 = 1 AND r1 <= 200) OR (s2 = 1 AND r2 <= 200) OR (s3 = 1 AND r3 <= 200)",
),
).toBe(true);
});
it("flags the steps in an inner subquery and partitions by the flag columns, so page steps plan", () => {
const sql = stepFieldSampleSql(s, def({ steps: pageSteps }), [], [PAY], 200);
assertJoinFree(sql);
expect(sql).not.toMatch(/PARTITION BY CASE/);
for (const i of [1, 2, 3])
expect(sql).toContain(
`ROW_NUMBER() OVER (PARTITION BY s${i} ORDER BY _timestamp DESC) AS r${i}`,
);
const inner = flagScan(sql);
expect(inner).toMatch(/^\(SELECT CASE WHEN .+ THEN 1 ELSE 0 END AS s1, /);
expect(inner).toMatch(/ THEN 1 ELSE 0 END AS s3, /);
expect(inner).toContain("_timestamp");
expect(inner).toContain(`"${PAY}"`);
expect(inner).not.toContain("ROW_NUMBER");
expect(sql.indexOf("ROW_NUMBER")).toBeLessThan(sql.lastIndexOf("(SELECT CASE WHEN"));
expect(sql.startsWith(`SELECT s1, s2, s3, "${PAY}" FROM (`)).toBe(true);
expect(
sql.endsWith(
"WHERE (s1 = 1 AND r1 <= 200) OR (s2 = 1 AND r2 <= 200) OR (s3 = 1 AND r3 <= 200)",
),
).toBe(true);
});
it("reads only the step flags when no field is sampled", () => {
const sql = stepFieldSampleSql(s, def(), [], [], 200);
expect(sql.startsWith("SELECT s1, s2, s3 FROM (SELECT s1, s2, s3, ROW_NUMBER()")).toBe(true);
expect(flagScan(sql)).toMatch(/ END AS s3, _timestamp $/);
});
});
describe("Why N? counts for the Filter all steps notice", () => {
const rules = {
bots: false,
builtinBots: { version: 1, families: [], devices: [] },
sessions: [{ field: "usr_id" as const, op: "eq" as const, values: ["bot"] }],
pages: [],
};
const ruled: AnalyticsScope = {
...s,
schema: { ...s.schema, usr_id: true },
exclusions: rules,
};
const CARRY = `MAX(CASE WHEN "${PAY}" IS NOT NULL AND CAST("${PAY}" AS VARCHAR) <> '' THEN 1 ELSE 0 END) AS hf1`;
it("counts the step-1 sessions that ever carry each session filter's field", () => {
const sql = whyEnteredSql(s, def({ f: [UPI] }), opts);
assertJoinFree(sql);
expect(cte(sql, "g")).toContain(CARRY);
expect(tail(sql)).toContain(
"SUM(CASE WHEN o1 = 1 AND hf1 = 1 THEN 1 ELSE 0 END) AS carry1 FROM g LIMIT 1",
);
expect(sql).toContain(` OR "${PAY}" IS NOT NULL)`);
});
it("keeps the filter, carry and identity flags through step 1's property condition", () => {
const d = def({
steps: [c("product_viewed", { pc: [UPI] }), steps[1], steps[2]],
f: [{ field: "context_plan", op: "in", values: ["pro"] }],
});
const sql = whyEnteredSql(ruled, d, opts);
assertJoinFree(sql);
const g0 = cte(sql, "g0");
expect(g0).toContain(" AS ki1");
expect(g0).toContain(" AS hf1");
expect(g0).toContain(" AS xi");
expect(g0).toContain("AS p1");
const g = cte(sql, "g");
expect(g).toContain("MAX(ki1) AS ki1, MAX(hf1) AS hf1, MAX(xi) AS xi FROM g0 GROUP BY sid");
expect(tail(sql)).toContain(
"SUM(CASE WHEN k1 = 1 AND ki1 = 1 THEN 1 ELSE 0 END) AS kept, SUM(CASE WHEN o1 = 1 AND hf1 = 1 THEN 1 ELSE 0 END) AS carry1 FROM g WHERE xi = 0 LIMIT 1",
);
});
});
@@ -218,7 +218,7 @@ describe("Why N? query", () => {
const sql = whyEnteredSql(scope(), fdef({ f: [COUNTRY, EMAIL] }), opts);
assertJoinFree(sql);
expect(sql).toContain(
"SELECT SUM(o1) AS opened, SUM(CASE WHEN k1 = 1 AND ki1 = 1 THEN 1 ELSE 0 END) AS kept FROM g LIMIT 1",
"SELECT SUM(o1) AS opened, SUM(CASE WHEN k1 = 1 AND ki1 = 1 THEN 1 ELSE 0 END) AS kept, SUM(CASE WHEN o1 = 1 AND hf1 = 1 THEN 1 ELSE 0 END) AS carry1 FROM g LIMIT 1",
);
expect(sql).toContain(`AND ${COUNTRY_PRED} THEN 1 ELSE 0 END) AS k1`);
expect(sql).toContain(EMAIL_KI);
@@ -610,9 +610,13 @@ describe("funnel builders (G4, AC-12, AC-14, AC-15, AC-47)", () => {
{ steps, unit: "sessions", window: "session", breakdown: "browser" },
opts,
);
// A built-in breakdown adds its per-value median from step 1, for the table's Median time column.
const expected = SEQ.Q7_FUNNEL_BREAKDOWN.replace(
"ORDER BY c1 DESC LIMIT 1000",
"ORDER BY c1 DESC, dim LIMIT 1000",
).replace(
" FROM ps GROUP BY dim",
", approx_percentile_cont(d3, 0.5) AS mt1 FROM ps GROUP BY dim",
);
expect(norm(sql, s)).toBe(norm(expected, s));
});
@@ -629,7 +633,7 @@ describe("funnel builders (G4, AC-12, AC-14, AC-15, AC-47)", () => {
);
expect(sql).toContain(", s1s, dim FROM su)");
expect(sql).toContain(
"SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3 FROM pu WHERE anon = 0 GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000",
"SELECT dim, SUM(r1) AS c1, SUM(r2) AS c2, SUM(r3) AS c3, approx_percentile_cont(d3, 0.5) AS mt1 FROM pu WHERE anon = 0 GROUP BY dim ORDER BY c1 DESC, dim LIMIT 1000",
);
});
@@ -1362,6 +1366,13 @@ describe("assertJoinFree over every builder (AC-55)", () => {
QUERIES.funnelSql(s, id, { ...d, breakdown: null }, opts),
]),
funnelPanelSql: () => defs.map((d) => QUERIES.funnelPanelSql(s, d, opts)),
funnelTrendSql: () =>
defs.flatMap((d) => [
QUERIES.funnelTrendSql(s, id, d, bounds, { a: 1, b: 2 }, opts),
QUERIES.funnelTrendSql(s, id, { ...d, breakdown: null }, bounds, { a: 1, b: 2 }, opts),
]),
funnelTrendPanelSql: () =>
defs.map((d) => QUERIES.funnelTrendPanelSql(s, d, { a: 1, b: 2 }, "1 day", opts)),
funnelAlertSql: () => defs.map((d) => QUERIES.funnelAlertSql(s, id, d, 50, opts)),
nextStepsSql: () => defs.map((d) => QUERIES.nextStepsSql(s, id, d, opts)),
dropoffNextSql: () =>
@@ -1434,6 +1445,10 @@ describe("assertJoinFree over every builder (AC-55)", () => {
userAgentSuggestionSql: () => [P5_NEW.userAgentSuggestionSql(s)],
namedEventPreviewSql: () => [P5_NEW.namedEventPreviewSql(s, ev)],
filterValuesSql: () => [QUERIES.filterValuesSql(s, "usr_email")],
stepFieldSampleSql: () =>
defs.map((d) =>
QUERIES.stepFieldSampleSql(s, d, [ev], ["context_payment_method", "usr_email"], 200),
),
whyEnteredSql: () =>
defs.map((d) =>
QUERIES.whyEnteredSql(
@@ -0,0 +1,197 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
assertJoinFree,
funnelSql,
funnelTrendPanelSql,
funnelTrendSql,
type AnalyticsScope,
type FunnelDef,
type FunnelPair,
} from "@/utils/rum/productAnalyticsQueries";
const s: AnalyticsScope = {
app: "web",
env: [],
version: [],
schema: {
action_target_name: true,
usr_email: true,
user_agent_user_agent_family: true,
view_name: true,
},
};
const id = { field: "usr_email" as const, excluded: [] };
const opts = { events: [], sample: 1 as const };
const steps = [
{ kind: "p" as const, key: "/shop" },
{ kind: "c" as const, key: "add-to-cart" },
{ kind: "p" as const, key: "/checkout" },
{ kind: "c" as const, key: "place-order" },
];
const DAY = 86_400_000_000;
const bounds = [0, 1, 2].map((i) => 1_791_417_600_000_000 + i * DAY);
const def = (patch: Partial<FunnelDef> = {}): FunnelDef => ({
steps,
unit: "sessions",
window: "session",
breakdown: null,
...patch,
});
const full: FunnelPair = { a: 1, b: 4 };
const ctesOf = (sql: string) => sql.slice(0, sql.lastIndexOf("\n"));
// The step-1 gate CTE reads one source; dropping it points its reader back at that source.
const withoutGate = (sql: string) => {
const m =
/,\nxe AS \(SELECT \*, MIN\(CASE WHEN m1 = 1 THEN t END\) OVER \(PARTITION BY [^)]*\)?\) AS t1w FROM (\w+)\)/.exec(
sql,
);
if (!m) return sql;
return sql
.replace(m[0], "")
.split("FROM xe ")
.join(`FROM ${m[1]} `)
.split(" AND t >= t1w")
.join("");
};
// Removes only what the trend adds, so the rest must equal the steps query's CTEs.
const withoutEntry = (sql: string, a: number) =>
withoutGate(ctesOf(sql))
.split(",\ntb AS (")[0]
.split(", MIN(_timestamp) AS ts")
.join("")
.split(`, MIN(CASE WHEN r${a} = 1 THEN entry_ts END) AS entry_ts`)
.join("")
.split(`, MIN(CASE WHEN m${a} = 1 THEN ts END) AS entry_ts`)
.join("")
.split(", entry_ts")
.join("")
.split(", ts")
.join("");
describe("funnelTrendSql (P0-9)", () => {
it("Sessions buckets each session by its server-time entry into step 1", () => {
const sql = funnelTrendSql(s, null, def(), bounds, full, opts);
expect(sql).toContain("MIN(_timestamp) AS ts");
expect(sql).toContain("MIN(CASE WHEN m1 = 1 THEN ts END) AS entry_ts");
expect(sql).not.toContain("THEN t END) AS entry_ts");
expect(sql.split("\n").at(-1)).toBe(
`SELECT CASE WHEN entry_ts < ${bounds[1]} THEN 0 WHEN entry_ts < ${bounds[2]} THEN 1 ELSE 2 END AS bk, SUM(r1) AS ca, SUM(r4) AS cb FROM ps WHERE r1 = 1 GROUP BY bk ORDER BY bk LIMIT 3`,
);
expect(sql).toMatchSnapshot();
});
it("Users in the same session enter at their earliest session that reached step a", () => {
const sql = funnelTrendSql(s, id, def({ unit: "users" }), bounds, { a: 2, b: 4 }, opts);
expect(sql).toContain("MIN(CASE WHEN m2 = 1 AND t >= t1w THEN ts END) AS entry_ts");
expect(sql).toContain("MIN(CASE WHEN r2 = 1 THEN entry_ts END) AS entry_ts");
expect(sql.split("\n").at(-1)).toMatch(
/SUM\(r2\) AS ca, SUM\(r4\) AS cb FROM pu WHERE anon = 0 AND r2 = 1 GROUP BY bk/,
);
expect(sql).toMatchSnapshot();
});
it("a later step a counts only from the unit's first step 1 on, across the user's window", () => {
const sql = funnelTrendSql(
s,
id,
def({ unit: "users", window: "7d" }),
bounds,
{ a: 2, b: 3 },
opts,
);
expect(sql).toContain(
"xe AS (SELECT *, MIN(CASE WHEN m1 = 1 THEN t END) OVER (PARTITION BY COALESCE(uid, 's:' || sid)) AS t1w FROM x2)",
);
expect(sql).toContain(
"MIN(CASE WHEN m2 = 1 AND t >= t1w THEN ts END) AS entry_ts FROM xe GROUP BY",
);
const sessions = funnelTrendSql(s, null, def(), bounds, { a: 2, b: 3 }, opts);
expect(sessions).toContain(
"xe AS (SELECT *, MIN(CASE WHEN m1 = 1 THEN t END) OVER (PARTITION BY sid) AS t1w FROM x1)",
);
expect(funnelTrendSql(s, null, def(), bounds, full, opts)).not.toContain("t1w");
});
it("Users within 7 days carry the entry time through x2 into su and pu", () => {
const sql = funnelTrendSql(s, id, def({ unit: "users", window: "7d" }), bounds, full, opts);
expect(sql).toMatch(/x2 AS \(SELECT sid, t, ty, k, m1, m2, m3, m4, uid, ts FROM x1/);
expect(sql).toMatch(/su AS \(SELECT .*MIN\(CASE WHEN m1 = 1 THEN ts END\) AS entry_ts FROM x2/);
expect(sql).toMatch(/pu AS \(SELECT anon, .*, s1s, entry_ts FROM su\)/);
expect(sql).toMatchSnapshot();
});
it("a breakdown keeps the top values by step-1 units and folds the rest, at buckets × (top + 1) rows", () => {
const sql = funnelTrendSql(s, null, def({ breakdown: "browser" }), bounds, full, opts);
expect(sql).toContain("DENSE_RANK() OVER (ORDER BY un, tot DESC, dim) AS rk");
expect(sql).toContain("CASE WHEN un = 0 AND rk <= 5 THEN dim END AS gd");
expect(sql.split("\n").at(-1)).toBe(
"SELECT gd AS dim, MIN(un) AS un, MAX(tot) AS tot, bk, SUM(ca) AS ca, SUM(cb) AS cb FROM tg GROUP BY gd, bk ORDER BY bk, gd LIMIT 18",
);
expect(sql).toMatchSnapshot();
});
it("a pair other than step 1 → the last step reads its own reach columns", () => {
const sql = funnelTrendSql(s, null, def(), bounds, { a: 2, b: 3 }, opts);
expect(sql).toContain("MIN(CASE WHEN m2 = 1 AND t >= t1w THEN ts END) AS entry_ts");
expect(sql.split("\n").at(-1)).toMatch(/SUM\(r2\) AS ca, SUM\(r3\) AS cb FROM ps WHERE r2 = 1/);
expect(sql).toMatchSnapshot();
});
it("reads _rumdata once and stays join-free", () => {
for (const d of [def(), def({ unit: "users", window: "1d", breakdown: "browser" })])
expect(() => assertJoinFree(funnelTrendSql(s, id, d, bounds, full, opts))).not.toThrow();
});
it.each([
["sessions", def(), null],
["users / same session", def({ unit: "users" }), id],
["users / 7d", def({ unit: "users", window: "7d" }), id],
["breakdown", def({ breakdown: "browser" }), null],
["users breakdown", def({ unit: "users", breakdown: "browser" }), id],
] as const)(
"%s: the trend only adds the entry time to the steps query's CTEs, which stay unchanged",
(_, d, who) => {
const steps = funnelSql(s, who, d, opts);
expect(steps).not.toContain("entry_ts");
expect(steps).not.toContain("AS ts");
for (const a of [1, 2])
expect(withoutEntry(funnelTrendSql(s, who, d, bounds, { a, b: 4 }, opts), a)).toBe(
ctesOf(steps),
);
},
);
});
describe("funnelTrendPanelSql (P0-9 dashboard line)", () => {
it("buckets each session's entry time with histogram, so the dashboard's picker drives it", () => {
const sql = funnelTrendPanelSql(
s,
def({ unit: "users", breakdown: "browser" }),
full,
"1 week",
opts,
);
expect(sql.split("\n").at(-1)).toBe(
"SELECT histogram(entry_ts, '1 week') AS x_axis_1, ROUND(100.0 * SUM(r4) / SUM(r1), 1) AS y_axis_1 FROM ps WHERE r1 = 1 GROUP BY x_axis_1 ORDER BY x_axis_1 ASC LIMIT 1000",
);
expect(sql).not.toContain("CASE WHEN entry_ts <");
expect(sql).not.toContain("dim");
expect(() => assertJoinFree(sql)).not.toThrow();
});
});
+263 -32
View File
@@ -15,6 +15,7 @@
import { sqlIn, sqlLiteral } from "@/utils/query/sqlFilterBuilder";
import {
BREAKDOWN_TOP,
CONTAINER_SUFFIXES,
FILTER_FIELDS,
type AppExclusions,
@@ -35,6 +36,8 @@ export interface StepFilters {
frustrated?: true;
errored?: true;
vc?: ViewCond[];
/** Property conditions: one of the step's own rows must match every rule. */
pc?: FunnelFilter[];
}
export interface StepRef {
kind: StepKind;
@@ -67,11 +70,17 @@ export interface IdentitySql {
export type FunnelUnit = "sessions" | "users";
export type FunnelWindow = "session" | "1h" | "1d" | "7d";
export type BreakdownDim = "browser" | "os" | "device" | "country" | "version" | "env" | "app";
/** An attribute breakdown: column `f`, read at 1-based step `s`, or at the first step that carries it when absent. */
export interface AttributeBreakdown {
f: string;
s?: number;
}
export type Breakdown = BreakdownDim | AttributeBreakdown;
export interface FunnelDef {
steps: StepRef[];
unit: FunnelUnit;
window: FunnelWindow;
breakdown: BreakdownDim | null;
breakdown: Breakdown | null;
x?: ExclusionStep[];
f?: FunnelFilter[];
}
@@ -112,6 +121,11 @@ export interface BuildOpts {
events: NamedEvent[];
sample: SampleRatio;
}
/** A trend's step pair, 1-based, `a` before `b`. */
export interface FunnelPair {
a: number;
b: number;
}
export interface RegexPass {
pattern: string;
replacement: string;
@@ -218,6 +232,10 @@ const pathsIncludeExpr = (scope: AnalyticsScope, include: PathsInclude): string
const passSql = (x: string, p: RegexPass): string =>
`regexp_replace(${x}, '${p.pattern}', '${p.replacement}'${p.global ? ", 'g'" : ""})`;
export function isAttributeBreakdown(b: Breakdown | null): b is AttributeBreakdown {
return typeof b === "object" && b !== null;
}
export function pageKeyExpr(
col: string,
schema: Record<string, boolean>,
@@ -443,8 +461,9 @@ const rowFilterMatch = (scope: AnalyticsScope, r: FunnelFilter): string =>
r.not ? `NOT ${filterMatch(scope, r)}` : filterMatch(scope, r);
// Identity and attribute values are missing from a session's first views, so they are checked per session.
const isSessionFilter = (r: FunnelFilter): boolean =>
(IDENTITY_FIELDS as readonly string[]).includes(r.field) || !isBuiltinFilter(r.field);
export function isSessionFilter(r: FunnelFilter): boolean {
return (IDENTITY_FIELDS as readonly string[]).includes(r.field) || !isBuiltinFilter(r.field);
}
const funnelFilters = (def: FunnelDef, perSession: boolean): FunnelFilter[] =>
(def.f ?? []).filter((r) => isSessionFilter(r) === perSession);
@@ -764,6 +783,30 @@ export function filterValuesSql(scope: AnalyticsScope, field: FunnelFilter["fiel
return `SELECT ${v} AS v, COUNT(DISTINCT session_id) AS sessions FROM "_rumdata" WHERE ${rowScope(scope)} AND ${filterColumn(field)} IS NOT NULL AND ${v} <> '' GROUP BY ${v} ORDER BY sessions DESC, v LIMIT 20`;
}
/** The latest `perStep` rows of each step's events with `fields`; `sN` flags the rows sampled for step N. */
export function stepFieldSampleSql(
scope: AnalyticsScope,
def: FunnelDef,
events: NamedEvent[],
fields: readonly string[],
perStep: number,
): string {
const preds = def.steps.map((s) => stepPredicateRaw(s, scope, events));
const n = range1(preds.length);
const hits = n.map((i) => `CASE WHEN ${preds[i - 1]} THEN 1 ELSE 0 END AS s${i}`);
// A row can match several steps, so each step ranks its own rows; by flag column, as a CASE partition fails DataFusion's plan check.
const ranks = n.map(
(i) => `ROW_NUMBER() OVER (PARTITION BY s${i} ORDER BY _timestamp DESC) AS r${i}`,
);
// A field name is parsed to [a-z0-9_] before it gets here, so quoting it is safe.
const cols = fields.filter((f) => has(scope, f)).map((f) => `"${f}"`);
const picked = cols.length ? `, ${cols.join(", ")}` : "";
const flags = n.map((i) => `s${i}`).join(", ");
const kept = n.map((i) => `(s${i} = 1 AND r${i} <= ${Math.trunc(perStep)})`).join(" OR ");
const flagged = `SELECT ${hits.join(", ")}${picked}, _timestamp FROM "_rumdata" WHERE ${scopeClause(scope)} AND (${preds.join(" OR ")})`;
return `SELECT ${flags}${picked} FROM (SELECT ${flags}, ${ranks.join(", ")}${picked} FROM (${flagged})) WHERE ${kept}`;
}
/** Top browser and device families over the scope without any exclusion, for the likely-bot suggestions. */
export function userAgentSuggestionSql(scope: AnalyticsScope): string {
const bare = { app: scope.app, env: scope.env, version: scope.version, schema: scope.schema };
@@ -1235,13 +1278,25 @@ export function viewCondRaw(c: ViewCond, scope: AnalyticsScope): string {
return `(${anyOf(hits)})`;
}
const propFlag = (i: number, j: number): string => `pc${i}_${j + 1}`;
// A step's group of rows matches a property rule when one of its own rows does.
const propCols = (scope: AnalyticsScope, def: FunnelDef): string[] =>
def.steps.flatMap((s, i) =>
(s.f?.pc ?? []).map(
(r, j) =>
`MAX(CASE WHEN ${filterMatch(scope, r)} THEN 1 ELSE 0 END) AS ${propFlag(i + 1, j)}`,
),
);
// view_time_spent is in nanoseconds.
const filterConds = (f: StepFilters | undefined, scope: AnalyticsScope): string =>
const filterConds = (f: StepFilters | undefined, scope: AnalyticsScope, i: number): string =>
[
...(f?.minMs !== undefined ? [`vts >= ${f.minMs * 1000000}`] : []),
...(f?.frustrated ? ["vf = 1"] : []),
...(f?.errored ? ["ve = 1"] : []),
...(f?.vc ?? []).map((c) => viewCondGrouped(c, scope)),
...(f?.pc ?? []).map((r, j) => sessionFilterKeep(r, propFlag(i, j))),
]
.map((c) => ` AND ${c}`)
.join("");
@@ -1252,20 +1307,20 @@ const stepFlags = (
events: NamedEvent[],
ungated: boolean,
): string => {
const step = (s: StepRef) =>
`${stepPredicateGrouped(s, scope, events)}${filterConds(s.f, scope)}`;
const step = (s: StepRef, i: number) =>
`${stepPredicateGrouped(s, scope, events)}${filterConds(s.f, scope, i)}`;
const near = (i: number, except = 0) =>
withinOf(def)
.filter((x) => x.e.a === i && x.j !== except)
.map((x) => ` AND (nx${x.j} IS NULL OR nx${x.j} - t > ${x.e.withinMs})`)
.join("");
return [
...def.steps.map((s, i) => flag(`${step(s)}${near(i + 1)}`, `m${i + 1}`)),
...def.steps.map((s, i) => flag(`${step(s, i + 1)}${near(i + 1)}`, `m${i + 1}`)),
...betweenOf(def).map((x) => flag(stepPredicateGrouped(x.e.step, scope, events), `mx${x.j}`)),
// Each gate is lifted alone, so a unit removed by another within exclusion is not counted here.
...(ungated
? withinOf(def).map((x) =>
flag(`${step(def.steps[x.e.a - 1])}${near(x.e.a, x.j)}`, `ma${x.j}`),
flag(`${step(def.steps[x.e.a - 1], x.e.a)}${near(x.e.a, x.j)}`, `ma${x.j}`),
)
: []),
].join(", ");
@@ -1290,6 +1345,7 @@ const scanCols = (scope: AnalyticsScope, def: FunnelDef, events: NamedEvent[]):
: []),
...(errorRowPredicates(scope, all, events).length ? [`MIN(${errorCol(scope)}) AS em`] : []),
...(needsRef(all, events) ? [`MIN(${viewCol(scope, "view_referrer", "VARCHAR")}) AS ref`] : []),
...propCols(scope, def),
];
};
@@ -1392,6 +1448,12 @@ const chainShape = (id: IdentitySql | null, def: FunnelDef): ChainShape => {
return { users, windowMs, partition: users && windowMs ? "COALESCE(uid, 's:' || sid)" : "sid" };
};
interface DimSql {
col: string;
attr: boolean;
step: number | null;
}
interface FunnelParts {
n1: number[];
between: Exclusion[];
@@ -1438,7 +1500,8 @@ const funnelCtes = (
id: IdentitySql | null,
def: FunnelDef,
events: NamedEvent[],
dim: string | null,
dim: DimSql | null,
entry: number | null = null,
): { ctes: string[]; users: boolean } => {
const { users, windowMs, partition } = chainShape(id, def);
const n = def.steps.length;
@@ -1463,10 +1526,11 @@ const funnelCtes = (
"MIN(view_url) AS url",
atnCol(scope),
...(users && id ? identityGroupCols(id) : []),
...(dim ? [`MIN(${dim}) AS dim`] : []),
...(dim ? [`MIN(${dim.col}) AS dim`] : []),
...(vnAvail ? ["MIN(view_name) AS vn"] : []),
...extra,
...(newSince !== null ? ["MIN(date) AS t0"] : []),
...(entry !== null ? ["MIN(_timestamp) AS ts"] : []),
...(pass ? [pass.xi] : []),
];
const rowFilter = [
@@ -1484,6 +1548,7 @@ const funnelCtes = (
...(url && vnAvail ? ["vn"] : []),
...extra.map(colAlias),
...(newSince !== null ? ["t0"] : []),
...(entry !== null ? ["ts"] : []),
];
const errorKeep = errorKeepPredicates(scope, all, events)
.map((e) => ` OR ${e}`)
@@ -1494,7 +1559,7 @@ const funnelCtes = (
`x00 AS (SELECT ${cols.join(", ")} FROM "_rumdata" WHERE ${scopeClause(scope)}${funnelFilterKeeps(scope, def)} AND (${rowFilter}${pass?.widen ?? ""}) GROUP BY session_id, type, ${groupKey(scope, errors.length > 0)})`,
`x0 AS (SELECT sid, t, ty, ${k}${carry.map((c) => `, ${c}`).join("")} FROM ${filtered("x00", pass, x0Where)})`,
...near,
`x1 AS (SELECT sid, t, ty, k${dim ? ", dim" : ""}, ${stepFlags(scope, def, events, true)}, ${users ? `${UID_WINDOW} AS uid` : "CAST(NULL AS VARCHAR) AS uid"}${newSince !== null ? ", t0" : ""} FROM ${near.length ? "x1a" : "x0"})`,
`x1 AS (SELECT sid, t, ty, k${dim ? ", dim" : ""}, ${stepFlags(scope, def, events, true)}, ${users ? `${UID_WINDOW} AS uid` : "CAST(NULL AS VARCHAR) AS uid"}${newSince !== null ? ", t0" : ""}${entry !== null ? ", ts" : ""} FROM ${near.length ? "x1a" : "x0"})`,
];
let source = "x1";
if (users) {
@@ -1503,7 +1568,16 @@ const funnelCtes = (
...p.between.map((x) => `mx${x.j}`),
...p.within.map((x) => `ma${x.j}`),
];
const x2Cols = ["sid", "t", "ty", "k", ...(dim ? ["dim"] : []), ...flags, "uid"];
const x2Cols = [
"sid",
"t",
"ty",
"k",
...(dim ? ["dim"] : []),
...flags,
"uid",
...(entry !== null ? ["ts"] : []),
];
const rows = flags.map((f) => `${f} = 1`).join(" OR ");
if (newSince !== null)
ctes.push(
@@ -1521,7 +1595,22 @@ const funnelCtes = (
const f = `sequence_step_times(${windowMs ?? 0}, t, ${stepConds(n)}) AS f`;
const exs = p.between.map((x) => `${exAgg(x.j)} AS ex${x.j}`);
const seen = p.n1.map((i) => `SUM(m${i}) AS seen${i}`);
const firstDim = "first_value(dim ORDER BY CASE WHEN m1 = 1 THEN 0 ELSE 1 END, t) AS dim";
const firstDim = dim ? sessionDim(dim, n) : "";
const covered = dim?.attr ? coverageCols(n) : [];
const h = covered.map(colAlias);
// A later step counts from the unit's first step 1 on, so a stray earlier event cannot move its bucket.
if (entry !== null && entry > 1) {
ctes.push(
`xe AS (SELECT *, MIN(CASE WHEN m1 = 1 THEN t END) OVER (PARTITION BY ${partition}) AS t1w FROM ${source})`,
);
source = "xe";
}
// Server time in µs, so buckets compare with range bounds; t is the device clock in ms.
const entryCols =
entry === null
? []
: [`MIN(CASE WHEN m${entry} = 1${entry > 1 ? " AND t >= t1w" : ""} THEN ts END) AS entry_ts`];
const entryCarry = entry !== null ? ["entry_ts"] : [];
if (users && windowMs) {
// A windowed sequence spans the user's sessions, so it aggregates the user's rows directly.
const su = [
@@ -1534,17 +1623,19 @@ const funnelCtes = (
...seen,
"COUNT(DISTINCT CASE WHEN m1 = 1 THEN sid END) AS s1s",
...(dim ? [firstDim] : []),
...covered,
...entryCols,
];
ctes.push(
`su AS (SELECT ${su.join(", ")} FROM ${source} GROUP BY ${partition}, CASE WHEN uid IS NULL THEN 1 ELSE 0 END)`,
`pu AS (SELECT anon, ${[...fromStepTimes(p, true), "s1s", ...(dim ? ["dim"] : [])].join(", ")} FROM su)`,
`pu AS (SELECT anon, ${[...fromStepTimes(p, true), "s1s", ...(dim ? ["dim"] : []), ...h, ...entryCarry].join(", ")} FROM su)`,
);
return { ctes, users };
}
const dimCols = dim ? [firstDim, "MIN(CASE WHEN m1 = 1 THEN t END) AS t1"] : [];
const dimCols = dim ? [firstDim, "MIN(CASE WHEN m1 = 1 THEN t END) AS t1", ...covered] : [];
ctes.push(
`s AS (SELECT ${["sid", "MAX(uid) AS uid", f, ...ungatedTimes(p), ...exs, ...seen, ...dimCols].join(", ")} FROM ${source} GROUP BY sid)`,
`ps AS (SELECT ${["sid", "uid", ...fromStepTimes(p, false), ...(dim ? ["dim", "t1"] : [])].join(", ")} FROM s)`,
`s AS (SELECT ${["sid", "MAX(uid) AS uid", f, ...ungatedTimes(p), ...exs, ...seen, ...dimCols, ...entryCols].join(", ")} FROM ${source} GROUP BY sid)`,
`ps AS (SELECT ${["sid", "uid", ...fromStepTimes(p, false), ...(dim ? ["dim", "t1", ...h] : []), ...entryCarry].join(", ")} FROM s)`,
);
if (users) {
const pu = [
@@ -1557,7 +1648,10 @@ const funnelCtes = (
...exclusionsOf(def).map(
({ j, e }) => `CASE WHEN MAX(r${e.b ?? e.a}) = 0 THEN MAX(xr${j}) ELSE 0 END AS xr${j}`,
),
...(dim ? ["first_value(dim ORDER BY t1) AS dim"] : []),
...(dim ? [userDim(dim)] : []),
...h.map((c) => `MAX(${c}) AS ${c}`),
// A user enters at the earliest session that reached the pair's first step.
...(entry !== null ? [`MIN(CASE WHEN r${entry} = 1 THEN entry_ts END) AS entry_ts`] : []),
];
ctes.push(
`pu AS (SELECT ${pu.join(", ")} FROM ps GROUP BY COALESCE(uid, 's:' || sid), CASE WHEN uid IS NULL THEN 1 ELSE 0 END)`,
@@ -1566,11 +1660,52 @@ const funnelCtes = (
return { ctes, users };
};
const breakdownField = (scope: AnalyticsScope, def: FunnelDef): string | null => {
if (!def.breakdown) return null;
const field = DIMENSIONS[def.breakdown];
const breakdownField = (scope: AnalyticsScope, def: FunnelDef): DimSql | null => {
const b = def.breakdown;
if (!b) return null;
if (isAttributeBreakdown(b)) {
if (!has(scope, b.f)) return null;
// Empty text reads as no value, and MIN would otherwise pick it over a real one.
// A pin past the last step can only come from a stale definition, and reads as automatic.
const step = b.s !== undefined && b.s <= def.steps.length ? b.s : null;
return { col: `NULLIF(${filterValue(b.f)}, '')`, attr: true, step };
}
const field = DIMENSIONS[b];
// Every scope already filters on application_id, so the stream always has it.
return field && (field === "application_id" || has(scope, field)) ? field : null;
return field && (field === "application_id" || has(scope, field))
? { col: field, attr: false, step: null }
: null;
};
// Auto reads the first value on any step's row; a pinned step reads only its own rows, NULL when never matched.
const sessionDim = (dim: DimSql, n: number): string => {
if (!dim.attr) return "first_value(dim ORDER BY CASE WHEN m1 = 1 THEN 0 ELSE 1 END, t) AS dim";
if (dim.step === null) {
const any = range1(n)
.map((i) => `m${i} = 1`)
.join(" OR ");
return `first_value(CASE WHEN ${any} THEN dim END ORDER BY CASE WHEN dim IS NOT NULL AND (${any}) THEN 0 ELSE 1 END, t) AS dim`;
}
const k = dim.step;
return `first_value(CASE WHEN m${k} = 1 THEN dim END ORDER BY CASE WHEN m${k} = 1 AND dim IS NOT NULL THEN 0 ELSE 1 END, t) AS dim`;
};
const userDim = (dim: DimSql): string =>
dim.attr
? "first_value(dim ORDER BY CASE WHEN dim IS NULL THEN 1 ELSE 0 END, t1) AS dim"
: "first_value(dim ORDER BY t1) AS dim";
// Whether the unit's step-i rows carry a value, so the page can find the step the field is first set at.
const coverageCols = (n: number): string[] =>
range1(n).map((i) => `MAX(CASE WHEN m${i} = 1 AND dim IS NOT NULL THEN 1 ELSE 0 END) AS h${i}`);
// Time from start step k to the last step, for each k the page may pick; d_i counts from step 1.
const breakdownTimes = (dim: DimSql, n: number): string[] => {
const pinned = dim.step === null ? range1(n - 1) : [dim.step];
const starts = (dim.attr ? pinned : [1]).filter((k) => k < n);
return starts.map(
(k) => `approx_percentile_cont(${k === 1 ? `d${n}` : `d${n} - d${k}`}, 0.5) AS mt${k}`,
);
};
export function funnelSql(
@@ -1587,6 +1722,10 @@ export function funnelSql(
const cnt = [
...range1(n).map((i) => `SUM(r${i}) AS c${i}`),
...xr.map((j) => `SUM(xr${j}) AS xr${j}`),
...(dim.attr
? range1(n).map((i) => `SUM(CASE WHEN r${i} = 1 THEN h${i} ELSE 0 END) AS dc${i}`)
: []),
...breakdownTimes(dim, n),
];
return withCtes(
ctes,
@@ -1618,6 +1757,45 @@ export function funnelSql(
);
}
/** Each unit's entry into `pair.a`, bucketed by `boundariesUs`; a breakdown keeps the top values at `rankStep` and folds the rest. */
export function funnelTrendSql(
scope: AnalyticsScope,
id: IdentitySql | null,
def: FunnelDef,
boundariesUs: number[],
pair: FunnelPair,
opts: BuildOpts,
rankStep = 1,
): string {
const dim = breakdownField(scope, def);
const { ctes, users } = funnelCtes(scope, id, def, opts.events, dim, pair.a);
const { a, b } = pair;
const bk = periodCase(boundariesUs, false, "entry_ts");
const m = boundariesUs.length;
const from = users ? "pu WHERE anon = 0" : "ps";
if (!dim) {
return withCtes(
ctes,
`SELECT ${bk} AS bk, SUM(r${a}) AS ca, SUM(r${b}) AS cb FROM ${from}${users ? " AND" : " WHERE"} r${a} = 1 GROUP BY bk ORDER BY bk LIMIT ${m}`,
);
}
// Ranked by units at the table's start step, so lines and table rows name the same values.
const k =
Number.isInteger(rankStep) && rankStep > 1 && rankStep <= def.steps.length ? rankStep : 1;
const top = `un = 0${k > 1 ? " AND tot > 0" : ""} AND rk <= ${BREAKDOWN_TOP}`;
return withCtes(
[
...ctes,
`tb AS (SELECT dim, CASE WHEN r${a} = 1 THEN ${bk} END AS bk, ${k > 1 ? "SUM(r1) AS c1, " : ""}SUM(r${k}) AS c${k}, SUM(r${a}) AS ca, SUM(r${b}) AS cb FROM ${from} GROUP BY dim, CASE WHEN r${a} = 1 THEN ${bk} END)`,
`tk AS (SELECT *, SUM(c${k}) OVER (PARTITION BY dim) AS tot${k > 1 ? ", SUM(c1) OVER (PARTITION BY dim) AS t1" : ""}, CASE WHEN dim IS NULL OR dim = '' THEN 1 ELSE 0 END AS un FROM tb)`,
// Ties at step k fall back to step-1 units, as the table's rows do.
`tr AS (SELECT *, DENSE_RANK() OVER (ORDER BY un, tot DESC, ${k > 1 ? "t1 DESC, " : ""}dim) AS rk FROM tk)`,
`tg AS (SELECT CASE WHEN ${top} THEN dim END AS gd, un, tot, ${k > 1 ? "t1, " : ""}bk, ca, cb FROM tr WHERE bk IS NOT NULL)`,
],
`SELECT gd AS dim, MIN(un) AS un, MAX(tot) AS tot, ${k > 1 ? "MAX(t1) AS t1, " : ""}bk, SUM(ca) AS ca, SUM(cb) AS cb FROM tg GROUP BY gd, bk ORDER BY bk, gd LIMIT ${m * (BREAKDOWN_TOP + 1)}`,
);
}
export function funnelPanelSql(scope: AnalyticsScope, def: FunnelDef, opts: BuildOpts): string {
const sessionsDef: FunnelDef = { ...def, unit: "sessions", window: "session", breakdown: null };
const { ctes } = funnelCtes(scope, null, sessionsDef, opts.events, null);
@@ -1638,6 +1816,23 @@ export function funnelPanelSql(scope: AnalyticsScope, def: FunnelDef, opts: Buil
);
}
/** The dashboard's trend line: histogram buckets each session's entry time, so the panel follows the dashboard's time picker. */
export function funnelTrendPanelSql(
scope: AnalyticsScope,
def: FunnelDef,
pair: FunnelPair,
interval: string,
opts: BuildOpts,
): string {
const sessionsDef: FunnelDef = { ...def, unit: "sessions", window: "session", breakdown: null };
const { ctes } = funnelCtes(scope, null, sessionsDef, opts.events, null, pair.a);
const { a, b } = pair;
return withCtes(
ctes,
`SELECT histogram(entry_ts, ${lit(interval)}) AS x_axis_1, ROUND(100.0 * SUM(r${b}) / SUM(r${a}), 1) AS y_axis_1 FROM ps WHERE r${a} = 1 GROUP BY x_axis_1 ORDER BY x_axis_1 ASC LIMIT 1000`,
);
}
// The alert service rejects any `SELECT *` in the text, so this path lists every column.
export function funnelAlertSql(
scope: AnalyticsScope,
@@ -1659,7 +1854,14 @@ export function funnelAlertSql(
);
}
/** Sessions that opened step 1 (`opened`) and those of them inside Filter all steps (`kept`), for "Why N?". */
// Whether any of the session's rows carries a value in a session filter's field, whatever the value.
const carriesFlags = (scope: AnalyticsScope, filters: FunnelFilter[]): string[] =>
filters.map(
(r, i) =>
`MAX(CASE WHEN ${has(scope, r.field) ? `${filterColumn(r.field)} IS NOT NULL AND ${filterValue(r.field)} <> ''` : "1 = 0"} THEN 1 ELSE 0 END) AS hf${i + 1}`,
);
/** Sessions that opened step 1 (`opened`), those inside Filter all steps (`kept`), and per session filter those that ever carry its field (`carryN`). */
export function whyEnteredSql(scope: AnalyticsScope, def: FunnelDef, opts: BuildOpts): string {
const s1 = def.steps[0];
const conds = (s1.f?.vc ?? []).map((c) => ` AND ${viewCondRaw(c, scope)}`).join("");
@@ -1669,21 +1871,50 @@ export function whyEnteredSql(scope: AnalyticsScope, def: FunnelDef, opts: Build
const base = sessionPass(scope);
const ids = funnelFilters(def, true);
const widen = `${base?.widen ?? ""}${sessionFilterWiden(scope, ids)}`;
const g = [
"session_id AS sid",
`MAX(CASE WHEN ${opened} THEN 1 ELSE 0 END) AS o1`,
`MAX(CASE WHEN ${kept} THEN 1 ELSE 0 END) AS k1`,
const pc = s1.f?.pc ?? [];
const where = `"_rumdata" WHERE ${scopeClause(scope, opts.sample)} AND (${opened}${widen})`;
const flags = [
...sessionFilterFlags(scope, ids),
...carriesFlags(scope, ids),
...(base ? [base.xi] : []),
];
const inFilter = ["k1 = 1", ...ids.map((r, i) => sessionFilterKeep(r, `ki${i + 1}`))].join(
" AND ",
);
const carry = ids.map(
(_, i) => `, SUM(CASE WHEN o1 = 1 AND hf${i + 1} = 1 THEN 1 ELSE 0 END) AS carry${i + 1}`,
);
const tail = `SELECT SUM(o1) AS opened, SUM(CASE WHEN ${inFilter} THEN 1 ELSE 0 END) AS kept${carry.join("")} FROM g${base ? " WHERE xi = 0" : ""} LIMIT 1`;
if (!pc.length) {
const g = [
"session_id AS sid",
`MAX(CASE WHEN ${opened} THEN 1 ELSE 0 END) AS o1`,
`MAX(CASE WHEN ${kept} THEN 1 ELSE 0 END) AS k1`,
...flags,
];
return withCtes([`g AS (SELECT ${g.join(", ")} FROM ${where} GROUP BY session_id)`], tail);
}
// As in the funnel, a property rule holds when any row of step 1's view or action group matches it.
const g0 = [
"session_id AS sid",
`MAX(CASE WHEN ${opened} THEN 1 ELSE 0 END) AS o0`,
`MAX(CASE WHEN ${kept} THEN 1 ELSE 0 END) AS k0`,
...pc.map((r, j) => `MAX(CASE WHEN ${filterMatch(scope, r)} THEN 1 ELSE 0 END) AS p${j + 1}`),
...flags,
];
const props = pc.map((r, j) => ` AND ${sessionFilterKeep(r, `p${j + 1}`)}`).join("");
const g = [
"sid",
`MAX(CASE WHEN o0 = 1${props} THEN 1 ELSE 0 END) AS o1`,
`MAX(CASE WHEN k0 = 1${props} THEN 1 ELSE 0 END) AS k1`,
...flags.map((c) => colAlias(c)).map((c) => `MAX(${c}) AS ${c}`),
];
return withCtes(
[
`g AS (SELECT ${g.join(", ")} FROM "_rumdata" WHERE ${scopeClause(scope, opts.sample)} AND (${opened}${widen}) GROUP BY session_id)`,
`g0 AS (SELECT ${g0.join(", ")} FROM ${where} GROUP BY session_id, type, ${groupKey(scope, true)})`,
`g AS (SELECT ${g.join(", ")} FROM g0 GROUP BY sid)`,
],
`SELECT SUM(o1) AS opened, SUM(CASE WHEN ${inFilter} THEN 1 ELSE 0 END) AS kept FROM g${base ? " WHERE xi = 0" : ""} LIMIT 1`,
tail,
);
}
@@ -2139,10 +2370,10 @@ export function branchSessionsSql(
}
// With `before`, rows ahead of the first column read as -1 rather than joining period 0.
const periodCase = (boundariesUs: number[], before = false): string => {
const periodCase = (boundariesUs: number[], before = false, col = "_timestamp"): string => {
const whens = [
...(before ? [`WHEN _timestamp < ${Math.trunc(boundariesUs[0])} THEN -1`] : []),
...boundariesUs.slice(1).map((b, i) => `WHEN _timestamp < ${Math.trunc(b)} THEN ${i}`),
...(before ? [`WHEN ${col} < ${Math.trunc(boundariesUs[0])} THEN -1`] : []),
...boundariesUs.slice(1).map((b, i) => `WHEN ${col} < ${Math.trunc(b)} THEN ${i}`),
].join(" ");
return `CASE ${whens} ELSE ${boundariesUs.length - 1} END`;
};