// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. mod common; use crate::common::*; use std::collections::HashMap; use std::thread; use std::time::Duration; use glean_core::{ metrics::EventMetric, CommonMetricData, Glean, InternalConfiguration, Lifetime, SessionMode, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn session_cfg( data_path: &str, mode: SessionMode, sample_rate: f64, timeout_ms: u64, ) -> InternalConfiguration { InternalConfiguration { data_path: data_path.to_string(), application_id: GLOBAL_APPLICATION_ID.into(), language_binding_name: "Rust".into(), upload_enabled: true, max_events: None, delay_ping_lifetime_io: false, app_build: "Unknown".into(), use_core_mps: false, trim_data_to_registered_pings: false, log_level: None, rate_limit: None, enable_event_timestamps: false, experimentation_id: None, enable_internal_pings: true, ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, max_pending_pings_count: None, max_pending_pings_directory_size: None, session_mode: mode, session_sample_rate: sample_rate, session_inactivity_timeout_ms: timeout_ms, } } /// Returns an EventMetric that matches glean.session_start boundary events. fn session_start_metric() -> EventMetric { EventMetric::new( CommonMetricData { name: "session_start".into(), category: "glean".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, ..Default::default() }, vec![], ) } /// Returns an EventMetric that matches glean.session_end boundary events. fn session_end_metric() -> EventMetric { EventMetric::new( CommonMetricData { name: "session_end".into(), category: "glean".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, ..Default::default() }, vec![], ) } // --------------------------------------------------------------------------- // Auto mode — basic lifecycle // --------------------------------------------------------------------------- /// First `handle_client_active` call starts a new session with seq=1. #[test] fn auto_mode_starts_session_on_first_active() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); // No session before the first activation. assert!(session_start_metric().get_value(&glean, "events").is_none()); glean.handle_client_active(); let events = session_start_metric() .get_value(&glean, "events") .expect("expected session_start event"); assert_eq!(1, events.len()); assert_eq!("glean", events[0].category); assert_eq!("session_start", events[0].name); let extra = events[0].extra.as_ref().expect("expected extras"); assert!( extra.contains_key("session_id"), "session_id missing from extras" ); assert_eq!("1", extra.get("session_seq").expect("session_seq missing")); } /// Reactivating within the inactivity timeout resumes the existing session — /// no new session_start or session_end events are emitted. #[test] fn auto_mode_resumes_session_within_timeout() { let (_t, data_path) = tempdir(); // 30-minute timeout — will not expire during this test. let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // Going inactive: Auto mode does not end the session immediately; instead // it records inactive_since and submits the events ping (clearing the store). glean.handle_client_inactive(); // Re-activate immediately — within the 30-minute timeout. glean.handle_client_active(); // The events store was cleared by handle_client_inactive's ping submission. // On resume, no new boundary events should be emitted. assert!( session_start_metric().get_value(&glean, "events").is_none(), "expected no new session_start on resume within timeout" ); assert!( session_end_metric().get_value(&glean, "events").is_none(), "expected no session_end on resume within timeout" ); } /// With inactivity_timeout_ms=0 the session should NEVER time out (always /// resumed on reactivation), regardless of how long the client was inactive. #[test] fn auto_mode_zero_timeout_means_never_time_out() { let (_t, data_path) = tempdir(); // 0 ms = "never time out". let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // Events store cleared by handle_client_inactive's ping submission. glean.handle_client_inactive(); // Sleep — should not matter for a zero-timeout session. thread::sleep(Duration::from_millis(20)); glean.handle_client_active(); // If timeout=0 were treated as "immediate timeout", we'd see a session_end // + new session_start here. With the correct "never time out" semantics, // the store should be empty (session was resumed, no new boundary events). assert!( session_start_metric().get_value(&glean, "events").is_none(), "timeout_ms=0 must mean 'never time out': no new session_start expected" ); assert!( session_end_metric().get_value(&glean, "events").is_none(), "timeout_ms=0 must mean 'never time out': no session_end expected" ); } /// After the inactivity timeout expires, the old session is ended with /// reason "timeout" and a new session is started. #[test] fn auto_mode_starts_new_session_after_timeout() { let (_t, data_path) = tempdir(); // 1 ms inactivity timeout — expires almost immediately. let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // Going inactive clears the events store (ping submitted). glean.handle_client_inactive(); // Sleep well beyond the 1 ms timeout. thread::sleep(Duration::from_millis(20)); glean.handle_client_active(); // session_end("timeout") should be present. let end_events = session_end_metric() .get_value(&glean, "events") .expect("expected session_end event after timeout"); assert_eq!(1, end_events.len()); let end_extra = end_events[0] .extra .as_ref() .expect("expected extras on session_end"); assert_eq!( "timeout", end_extra .get("reason") .expect("reason missing from session_end") ); // A new session_start should also be present. let start_events = session_start_metric() .get_value(&glean, "events") .expect("expected session_start after timeout"); assert_eq!(1, start_events.len()); let start_extra = start_events[0] .extra .as_ref() .expect("expected extras on session_start"); assert_eq!( "2", start_extra .get("session_seq") .expect("session_seq missing from session_start"), "second session must have seq=2" ); } // --------------------------------------------------------------------------- // Lifecycle mode // --------------------------------------------------------------------------- /// In Lifecycle mode each active/inactive cycle produces a distinct session. #[test] fn lifecycle_mode_new_session_per_activation() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); // First activation. glean.handle_client_active(); let starts = session_start_metric() .get_value(&glean, "events") .expect("expected first session_start"); assert_eq!(1, starts.len()); assert_eq!( "1", starts[0] .extra .as_ref() .unwrap() .get("session_seq") .unwrap() ); // Deactivate — session_end is recorded, events ping submitted (clears store). glean.handle_client_inactive(); // Second activation — new session with seq=2. glean.handle_client_active(); let starts2 = session_start_metric() .get_value(&glean, "events") .expect("expected second session_start"); assert_eq!(1, starts2.len()); assert_eq!( "2", starts2[0] .extra .as_ref() .unwrap() .get("session_seq") .unwrap(), "second session must have seq=2" ); } /// In Lifecycle mode `handle_client_inactive` immediately emits session_end. #[test] fn lifecycle_mode_session_end_on_inactive() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // Capture the session_id from the start event before inactive clears the store. let start_events = session_start_metric() .get_value(&glean, "events") .expect("expected session_start"); let started_id = start_events[0] .extra .as_ref() .unwrap() .get("session_id") .unwrap() .clone(); // Record the end event into a temporary store we can observe before the // events ping clears it. We insert a user event first so we can check // it was also recorded (sampled in). glean.handle_client_inactive(); // After handle_client_inactive the events ping was submitted and store // cleared — but the session_end was written before that submission, so it // appeared in the ping. We can confirm by verifying the store is now empty // (session_end was consumed by the ping submission). assert!( session_end_metric().get_value(&glean, "events").is_none(), "session_end store must be cleared after events ping submission" ); // After inactive, session should no longer be active. assert!( !glean.session_manager().is_active(), "session must be inactive after handle_client_inactive in LIFECYCLE mode" ); // The started session id must be a valid UUID. assert!( uuid::Uuid::parse_str(&started_id).is_ok(), "session_id must be a valid UUID" ); } // --------------------------------------------------------------------------- // Manual mode // --------------------------------------------------------------------------- /// In Manual mode `handle_client_active` and `handle_client_inactive` must not /// create or destroy sessions automatically. #[test] fn manual_mode_no_auto_sessions() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Manual, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); assert!( session_start_metric().get_value(&glean, "events").is_none(), "manual mode: handle_client_active must not start a session" ); glean.handle_client_inactive(); assert!( session_end_metric().get_value(&glean, "events").is_none(), "manual mode: handle_client_inactive must not end a session" ); } // --------------------------------------------------------------------------- // Session sequence counter // --------------------------------------------------------------------------- /// session_seq must be strictly monotonically increasing across sessions. #[test] fn session_seq_monotonically_increases_across_sessions() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); for expected_seq in 1u64..=4 { glean.handle_client_active(); let starts = session_start_metric() .get_value(&glean, "events") .unwrap_or_else(|| panic!("expected session_start at seq={expected_seq}")); let seq_str = starts .last() .unwrap() .extra .as_ref() .unwrap() .get("session_seq") .unwrap() .clone(); assert_eq!( expected_seq.to_string(), seq_str, "session_seq mismatch at iteration {expected_seq}" ); glean.handle_client_inactive(); } } /// session_seq must survive a Glean restart (persist across process boundaries). #[test] fn session_seq_persists_across_restarts() { let (t, data_path) = tempdir(); { let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // seq=1 glean.handle_client_inactive(); glean.handle_client_active(); // seq=2 glean.handle_client_inactive(); // glean drops here, persisting seq=2 to storage. } // Simulate restart — new Glean on the same data path. let cfg2 = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean2 = Glean::new(cfg2).unwrap(); glean2.handle_client_active(); // should be seq=3 let starts = session_start_metric() .get_value(&glean2, "events") .expect("expected session_start after restart"); assert_eq!( "3", starts[0] .extra .as_ref() .unwrap() .get("session_seq") .unwrap(), "session_seq must continue from last persisted value after restart" ); drop(t); // keep TempDir alive until here } // --------------------------------------------------------------------------- // Sampling gate // --------------------------------------------------------------------------- /// With sample_rate=0.0 every session is sampled out, so user events are /// suppressed but out-of-session boundary events (session_start) are not. #[test] fn sampling_rate_zero_blocks_user_events_within_session() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); assert!( user_event.get_value(&glean, "events").is_none(), "sample_rate=0.0: user event must be suppressed inside a sampled-out session" ); // Boundary event (in_session=false) must still be recorded. assert!( session_start_metric().get_value(&glean, "events").is_some(), "session_start (in_session=false) must not be suppressed by the sampling gate" ); } /// With sample_rate=1.0 every session is sampled in and user events pass through. #[test] fn sampling_rate_one_passes_all_user_events() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); assert!( user_event.get_value(&glean, "events").is_some(), "sample_rate=1.0: user event must be recorded inside a sampled-in session" ); } /// Events recorded outside any session (before the first handle_client_active) /// are never suppressed regardless of sample_rate. #[test] fn events_outside_session_bypass_sampling_gate() { let (_t, data_path) = tempdir(); // sample_rate=0.0 — all sessions sampled out. let cfg = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let glean = Glean::new(cfg).unwrap(); // No handle_client_active — no session is active. let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); // Between sessions is_sampled_in() returns true. assert!( user_event.get_value(&glean, "events").is_some(), "events recorded between sessions must not be suppressed" ); } /// Metrics with in_session=false bypass the sampling gate even inside a /// sampled-out session. #[test] fn out_of_session_events_bypass_sampling_gate() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // session sampled out (rate=0.0) let oos_event = EventMetric::new( CommonMetricData { name: "oos_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, ..Default::default() }, vec![], ); oos_event.record_sync(&glean, 1000, HashMap::new(), 0); assert!( oos_event.get_value(&glean, "events").is_some(), "in_session=false event must bypass the sampling gate" ); } /// A sample_rate below 0.0 is clamped to 0.0 (all events suppressed). #[test] fn sample_rate_below_zero_clamped_to_zero() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, -0.5, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); assert!( user_event.get_value(&glean, "events").is_none(), "sample_rate=-0.5 must be clamped to 0.0, suppressing user events" ); } /// A sample_rate above 1.0 is clamped to 1.0 (all events recorded). #[test] fn sample_rate_above_one_clamped_to_one() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.5, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); assert!( user_event.get_value(&glean, "events").is_some(), "sample_rate=1.5 must be clamped to 1.0, recording user events" ); } // --------------------------------------------------------------------------- // Session metadata on events // --------------------------------------------------------------------------- /// In-session events must carry session metadata (session_id, session_seq, event_seq). #[test] fn session_metadata_attached_to_in_session_events() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected event"); let session = events[0] .session .as_ref() .expect("expected session metadata on in-session event"); assert!( !session.session_id.is_empty(), "session_id must not be empty" ); assert_eq!( 1, session.session_seq, "session_seq must be 1 for first session" ); assert_eq!(0, session.event_seq, "event_seq of first event must be 0"); assert!( (session.session_sample_rate - 1.0).abs() < f64::EPSILON, "session_sample_rate must match configured rate" ); } /// Out-of-session events must NOT carry session metadata. #[test] fn out_of_session_events_have_no_session_metadata() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let oos_event = EventMetric::new( CommonMetricData { name: "oos_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, ..Default::default() }, vec![], ); oos_event.record_sync(&glean, 1000, HashMap::new(), 0); let events = oos_event .get_value(&glean, "events") .expect("expected event"); assert!( events[0].session.is_none(), "in_session=false event must have no session metadata" ); } /// event_seq increments atomically with each in-session event. #[test] fn event_seq_increments_within_session() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); user_event.record_sync(&glean, 1001, HashMap::new(), 0); user_event.record_sync(&glean, 1002, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected events"); assert_eq!(3, events.len()); let seqs: Vec = events .iter() .map(|e| { e.session .as_ref() .expect("session metadata missing") .event_seq }) .collect(); assert_eq!( vec![0, 1, 2], seqs, "event_seq must increment with each event" ); } /// event_seq resets to 0 when a new session starts. #[test] fn event_seq_resets_on_new_session() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); // First session: record two events. glean.handle_client_active(); user_event.record_sync(&glean, 1000, HashMap::new(), 0); user_event.record_sync(&glean, 1001, HashMap::new(), 0); // handle_client_inactive submits events ping, clearing the store. glean.handle_client_inactive(); // Second session: record one event — event_seq should restart from 0. glean.handle_client_active(); user_event.record_sync(&glean, 2000, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected event in second session"); assert_eq!( 1, events.len(), "only event from second session should be present" ); let session = events[0] .session .as_ref() .expect("session metadata missing"); assert_eq!( 0, session.event_seq, "event_seq must reset to 0 at the start of each new session" ); assert_eq!(2, session.session_seq, "second session must have seq=2"); } // --------------------------------------------------------------------------- // Manual mode — explicit session APIs // --------------------------------------------------------------------------- /// In Manual mode, calling `session_start` and `session_end` directly produces /// the expected boundary events and session metadata. #[test] fn manual_mode_explicit_session_start_end() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Manual, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); // No session yet — lifecycle signals are no-ops in Manual mode. assert!(!glean.session_manager().is_active()); glean.session_start(); assert!( glean.session_manager().is_active(), "session must be active after manual session_start" ); let starts = session_start_metric() .get_value(&glean, "events") .expect("expected session_start event after manual start"); assert_eq!(1, starts.len()); let extra = starts[0].extra.as_ref().unwrap(); assert_eq!( "1", extra.get("session_seq").unwrap(), "first manual session must have seq=1" ); assert!( uuid::Uuid::parse_str(extra.get("session_id").unwrap()).is_ok(), "session_id must be a valid UUID" ); // Record a user event — it should carry session metadata. let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected event"); assert!( events[0].session.is_some(), "in-session event must have session metadata in Manual mode" ); // End the session explicitly. glean.session_end(Some("done")); assert!( !glean.session_manager().is_active(), "session must be inactive after manual session_end" ); } /// Starting a second manual session increments session_seq to 2. #[test] fn manual_mode_second_session_has_seq_2() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Manual, 1.0, 0); let mut glean = Glean::new(cfg).unwrap(); glean.session_start(); glean.session_end(None); // events ping cleared by ping submission in session tests; reset store manually // by restarting (this test uses the same ping store, so we check seq from the // session_start extra in the second start which is visible after the first end). glean.session_start(); let starts = session_start_metric() .get_value(&glean, "events") .expect("expected session_start for second session"); // After the first session_end the events ping was submitted (store cleared by // handle_client_inactive in lifecycle mode, but Manual mode doesn't submit pings // automatically). We read whatever is in the store after the second start. let seq_str = starts .last() .unwrap() .extra .as_ref() .unwrap() .get("session_seq") .unwrap() .clone(); assert_eq!("2", seq_str, "second manual session must have seq=2"); } // --------------------------------------------------------------------------- // AUTO mode — session resumption and timeout across restarts // --------------------------------------------------------------------------- /// Clean restart before the inactivity timeout: the session is resumed, so no /// new session_start or session_end boundary events are emitted on reactivation. #[test] fn auto_mode_session_resumed_on_restart_before_timeout() { let (t, data_path) = tempdir(); let original_session_id; { // 30-minute timeout — won't expire during this test. let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // starts session, persists state original_session_id = glean.session_manager().session_id().unwrap().to_string(); glean.handle_client_inactive(); // records inactive_since, submits events ping // Drop glean — simulates a clean process exit. } // Restart on the same data path. let cfg2 = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean2 = Glean::new(cfg2).unwrap(); // Re-activate immediately — well within the 30-minute timeout. glean2.handle_client_active(); // No new boundary events should be in the store (session was resumed). assert!( session_start_metric() .get_value(&glean2, "events") .is_none(), "no new session_start expected when session is resumed after restart" ); assert!( session_end_metric().get_value(&glean2, "events").is_none(), "no session_end expected when session is resumed after restart" ); // The same session_id must still be active. assert_eq!( original_session_id, glean2.session_manager().session_id().unwrap().to_string(), "resumed session must keep the original session_id" ); drop(t); } /// In AUTO mode, `event_seq` must be monotonically continuous across a clean /// restart when the session is resumed. Events recorded before the restart /// had seq 0, 1, 2. After restart and re-activation, the first new event /// must continue from seq 3, not reset to 0. #[test] fn auto_mode_event_seq_continuous_across_restart() { let (t, data_path) = tempdir(); let pre_restart_seq; { let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "pre_restart_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); // Record three events — they will get event_seq 0, 1, 2. user_event.record_sync(&glean, 100, HashMap::new(), 0); user_event.record_sync(&glean, 200, HashMap::new(), 0); user_event.record_sync(&glean, 300, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected pre-restart events"); pre_restart_seq = events .last() .unwrap() .session .as_ref() .expect("session metadata missing") .event_seq; assert_eq!( 2, pre_restart_seq, "last pre-restart event must have event_seq=2" ); // Go inactive to persist event_seq before the simulated restart. glean.handle_client_inactive(); // Drop — simulates clean process exit. } // Restart on the same data path. let cfg2 = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean2 = Glean::new(cfg2).unwrap(); // Re-activate within the timeout — session is resumed, not replaced. glean2.handle_client_active(); let post_event = EventMetric::new( CommonMetricData { name: "post_restart_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); post_event.record_sync(&glean2, 400, HashMap::new(), 0); let post_events = post_event .get_value(&glean2, "events") .expect("expected post-restart event"); let post_seq = post_events[0] .session .as_ref() .expect("session metadata missing on post-restart event") .event_seq; assert_eq!( pre_restart_seq + 1, post_seq, "event_seq must continue from {} after restart, not reset to 0", pre_restart_seq ); drop(t); } /// Clean restart after the inactivity timeout: old session is ended with /// reason "timeout" and a new session is started. #[test] fn auto_mode_new_session_on_restart_after_timeout() { let (t, data_path) = tempdir(); let original_session_id; { // 1 ms timeout — expires almost immediately. let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); original_session_id = glean.session_manager().session_id().unwrap().to_string(); glean.handle_client_inactive(); // records inactive_since, clears store // Drop — clean exit. } thread::sleep(Duration::from_millis(20)); // ensure timeout has expired let cfg2 = session_cfg(&data_path, SessionMode::Auto, 1.0, 1); let mut glean2 = Glean::new(cfg2).unwrap(); glean2.handle_client_active(); // session_end("timeout") must appear. let end_events = session_end_metric() .get_value(&glean2, "events") .expect("expected session_end after timeout on restart"); assert_eq!(1, end_events.len()); assert_eq!( "timeout", end_events[0].extra.as_ref().unwrap().get("reason").unwrap() ); // The new session must have a different session_id. let new_id = glean2.session_manager().session_id().unwrap().to_string(); assert_ne!( original_session_id, new_id, "new session must have a fresh session_id" ); // New session must have seq=2. let start_events = session_start_metric() .get_value(&glean2, "events") .expect("expected session_start for new session after timeout"); assert_eq!( "2", start_events[0] .extra .as_ref() .unwrap() .get("session_seq") .unwrap() ); drop(t); } /// A session that was sampled-out must remain sampled-out after a clean restart /// in AUTO mode (sampled_in is recomputed deterministically from the UUID). #[test] fn auto_mode_sampled_out_session_stays_sampled_out_after_restart() { let (t, data_path) = tempdir(); // Find a UUID that will be deterministically sampled-out at rate 0.5. // We use rate=0.0 to guarantee every session is sampled out. { let cfg = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // session starts, sampled_in=false assert!( !glean.session_manager().sampled_in(), "session must be sampled-out at rate=0.0" ); glean.handle_client_inactive(); // records inactive_since } // Restart — session should resume and still be sampled-out. let cfg2 = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let mut glean2 = Glean::new(cfg2).unwrap(); glean2.handle_client_active(); // should resume, not start new session assert!( !glean2.session_manager().sampled_in(), "resumed session must remain sampled-out after restart" ); // User event must still be suppressed. let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean2, 1000, HashMap::new(), 0); assert!( user_event.get_value(&glean2, "events").is_none(), "user event must remain suppressed in resumed sampled-out session" ); drop(t); } /// `session_start_time` is persisted and available on events recorded after a /// clean restart that resumes an existing AUTO mode session. #[test] fn auto_mode_session_start_time_persists_across_restart() { let (t, data_path) = tempdir(); let original_start_time; { let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); original_start_time = glean .session_manager() .session_start_time() .expect("session_start_time must be set"); glean.handle_client_inactive(); } let cfg2 = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean2 = Glean::new(cfg2).unwrap(); glean2.handle_client_active(); // resumes session let resumed_start_time = glean2 .session_manager() .session_start_time() .expect("session_start_time must be restored after restart"); assert_eq!( original_start_time, resumed_start_time, "session_start_time must be the same before and after a clean restart" ); drop(t); } // --------------------------------------------------------------------------- // sessions_seen diagnostic counter // --------------------------------------------------------------------------- /// `sessions_seen` is incremented for every session start, including sampled-out ones. #[test] fn sessions_seen_increments_regardless_of_sampling() { use glean_core::metrics::CounterMetric; let (_t, data_path) = tempdir(); // Use rate=0.0 so all sessions are sampled out — sessions_seen must still increment. let cfg = session_cfg(&data_path, SessionMode::Lifecycle, 0.0, 0); let mut glean = Glean::new(cfg).unwrap(); let sessions_seen = CounterMetric::new(CommonMetricData { name: "sessions_seen".into(), category: "glean".into(), send_in_pings: vec!["metrics".into()], lifetime: Lifetime::Ping, in_session: false, ..Default::default() }); // No sessions started yet. assert!( sessions_seen.get_value(&glean, "metrics").is_none(), "sessions_seen must be 0 before any session starts" ); // Start and end three sessions. for _ in 0..3 { glean.handle_client_active(); glean.handle_client_inactive(); } assert_eq!( 3, sessions_seen.get_value(&glean, "metrics").unwrap_or(0), "sessions_seen must equal the number of sessions started, even when all are sampled-out" ); } /// `sessions_seen` is an out-of-session metric — it is never suppressed by /// session sampling and carries no session metadata. #[test] fn sessions_seen_is_out_of_session() { use glean_core::metrics::CounterMetric; let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 0.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); // session sampled-out let sessions_seen = CounterMetric::new(CommonMetricData { name: "sessions_seen".into(), category: "glean".into(), send_in_pings: vec!["metrics".into()], lifetime: Lifetime::Ping, in_session: false, ..Default::default() }); // Even with rate=0.0 the counter must have been recorded. assert_eq!( 1, sessions_seen.get_value(&glean, "metrics").unwrap_or(0), "sessions_seen must be recorded even when the session is sampled-out" ); } /// All in-session events within the same session share the same session_id. #[test] fn in_session_events_share_session_id() { let (_t, data_path) = tempdir(); let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); let user_event = EventMetric::new( CommonMetricData { name: "test_event".into(), category: "test".into(), send_in_pings: vec!["events".into()], lifetime: Lifetime::Ping, in_session: true, ..Default::default() }, vec![], ); user_event.record_sync(&glean, 1000, HashMap::new(), 0); user_event.record_sync(&glean, 1001, HashMap::new(), 0); let events = user_event .get_value(&glean, "events") .expect("expected events"); assert_eq!(2, events.len()); let id0 = &events[0].session.as_ref().unwrap().session_id; let id1 = &events[1].session.as_ref().unwrap().session_id; assert_eq!(id0, id1, "both events must share the same session_id"); assert!(!id0.is_empty(), "session_id must not be empty"); } // --------------------------------------------------------------------------- // Mode-mismatch across builds — orphaned session cleanup // --------------------------------------------------------------------------- /// Switching from Auto to Lifecycle mode across builds emits a synthetic /// session_end("abandoned") for the orphaned Auto session and clears storage. #[test] fn mode_switch_auto_to_lifecycle_emits_abandoned_session_end() { let (t, data_path) = tempdir(); let original_session_id; { let cfg = session_cfg(&data_path, SessionMode::Auto, 1.0, 1_800_000); let mut glean = Glean::new(cfg).unwrap(); glean.handle_client_active(); original_session_id = glean.session_manager().session_id().unwrap().to_string(); glean.handle_client_inactive(); // persists session_id + inactive_since } // Restart with Lifecycle mode — the Auto session is now orphaned. let cfg2 = session_cfg(&data_path, SessionMode::Lifecycle, 1.0, 0); let glean2 = Glean::new(cfg2).unwrap(); // A synthetic session_end("abandoned") must have been emitted during init. let end_events = session_end_metric() .get_value(&glean2, "events") .expect("expected session_end(\"abandoned\") for orphaned session"); assert_eq!(1, end_events.len()); let extra = end_events[0] .extra .as_ref() .expect("expected extras on session_end"); assert_eq!( "abandoned", extra.get("reason").unwrap(), "orphaned session must produce reason='abandoned'" ); assert_eq!( &original_session_id, extra.get("session_id").unwrap(), "abandoned session_end must carry the original session_id" ); // The orphaned session state must be fully cleared — a new Lifecycle // session should start cleanly. assert!( glean2.session_manager().session_id().is_none(), "session_id must be cleared after orphaned session cleanup" ); drop(t); }