diff --git a/rust/cymbal/src/modes/processing/normalization.rs b/rust/cymbal/src/modes/processing/normalization.rs index c79374c0828f..f976d717cc76 100644 --- a/rust/cymbal/src/modes/processing/normalization.rs +++ b/rust/cymbal/src/modes/processing/normalization.rs @@ -124,7 +124,8 @@ fn lib_rules() -> &'static [LibRule] { rules.push(LibRule { lib: "posthog-python", fix: WireOrderFix::EXCEPTION_LIST, - canonical_since: None, + // posthog-python ships the flip in 7.31.0 (PostHog/posthog-python#728). + canonical_since: Some(Version::new(7, 31, 0)), }); rules @@ -331,6 +332,58 @@ mod test { assert!(legacy_wire_order(None, &canonical).is_none()); } + #[test] + fn python_cutoff_gates_exception_list_normalization_by_version() { + // Python's fix reverses the exception list (root-cause-first -> + // outermost-first), not frames — the gate must key on the same fix. + let make_list = || -> ExceptionList { + vec![ + exception_with_frames("RootCause", &["main", "boom"]), + exception_with_frames("Wrapper", &["main", "wrap"]), + ] + .into() + }; + + for version in [Some("7.30.1"), Some("7.30.0"), Some("garbage"), None] { + let mut list = make_list(); + let legacy = normalize_wire_order(&mut list, Some("posthog-python"), version); + assert!(legacy.is_some(), "{version:?} should normalize"); + assert_eq!(list[0].exception_type, "Wrapper", "list reversed"); + assert_eq!( + frame_names(&list[0]), + vec!["main", "wrap"], + "frames untouched" + ); + } + + // Post-flip senders emit canonical order: outermost wrapper first. + let make_canonical_list = || -> ExceptionList { + vec![ + exception_with_frames("Wrapper", &["main", "wrap"]), + exception_with_frames("RootCause", &["main", "boom"]), + ] + .into() + }; + + for version in ["7.31.0", "7.31.1", "7.32.0", "8.0.0"] { + let mut list = make_canonical_list(); + let legacy = normalize_wire_order(&mut list, Some("posthog-python"), Some(version)); + assert!(legacy.is_none(), "{version} should pass through"); + assert_eq!( + list[0].exception_type, "Wrapper", + "canonical list untouched" + ); + } + + // Legacy hashing still reconstructs the pre-flip (root-cause-first) + // list order from the canonical stored order post-cutoff. + let canonical = make_canonical_list(); + let legacy = legacy_wire_order(Some("posthog-python"), &canonical) + .expect("reconstruction must ignore the cutoff"); + assert_eq!(legacy[0].exception_type, "RootCause"); + assert_eq!(frame_names(&legacy[0]), vec!["main", "boom"]); + } + #[test] fn elixir_cutoff_gates_normalization_by_version() { for version in [Some("2.12.1"), Some("2.1.0"), Some("garbage"), None] {