glean_core/session/mod.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Session management for Glean.
6//!
7//! Sessions provide first-class boundaries for user activity, enabling
8//! session-level sampling, explicit start/end events, and per-event session
9//! metadata for downstream analysis.
10
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::Duration;
13
14use chrono::{DateTime, FixedOffset, SecondsFormat};
15use malloc_size_of_derive::MallocSizeOf;
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19use crate::metrics::{QuantityMetric, StringMetric};
20use crate::storage::INTERNAL_STORAGE;
21use crate::{CommonMetricData, Glean, Lifetime};
22
23// Storage key names for session persistence.
24const SESSION_SEQ_METRIC_NAME: &str = "session#seq";
25const SESSION_ID_METRIC_NAME: &str = "session#id";
26const SESSION_INACTIVE_SINCE_METRIC_NAME: &str = "session#inactive_since";
27const SESSION_START_TIME_METRIC_NAME: &str = "session#start_time";
28const SESSION_EVENT_SEQ_METRIC_NAME: &str = "session#event_seq";
29
30/// How sessions are managed by Glean.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default, MallocSizeOf)]
32pub enum SessionMode {
33 /// Glean automatically manages session boundaries based on client activity.
34 /// Sessions end after a configurable inactivity timeout.
35 #[default]
36 Auto,
37 /// A new session starts on every client-active/inactive transition.
38 Lifecycle,
39 /// Sessions are managed manually by the application.
40 ///
41 /// `handle_client_active` and `handle_client_inactive` have no effect on
42 /// session state. The application must call `glean_session_start()` and
43 /// `glean_session_end()` explicitly.
44 ///
45 /// Telemetry recorded before the first `glean_session_start()` call is
46 /// treated as between-session telemetry: it is not suppressed by session
47 /// sampling and carries no session metadata.
48 Manual,
49}
50
51/// The state of the current session.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum SessionState {
54 /// No active session.
55 Inactive,
56 /// A session is currently active.
57 Active,
58}
59
60/// Session metadata attached to each in-session event.
61///
62/// Serialized into the event payload for downstream session-level analysis.
63///
64/// `PartialEq` is derived (using `f64::eq` for `session_sample_rate`).
65/// `Eq` is implemented manually — it is sound because `session_sample_rate`
66/// is always clamped to `[0.0, 1.0]` and is therefore never NaN.
67/// Tests should prefer asserting on individual fields rather than whole-struct
68/// equality for clarity.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MallocSizeOf)]
70pub struct SessionMetadata {
71 /// The unique UUID for this session.
72 pub session_id: String,
73 /// Monotonically increasing session counter, persisted across restarts.
74 pub session_seq: u64,
75 /// Per-session event counter, reset at each new session.
76 pub event_seq: u64,
77 /// The sampling rate in effect for this session.
78 pub session_sample_rate: f64,
79 /// Wall-clock timestamp at session start (RFC 3339).
80 /// Absent on events from before sessions introduced this field.
81 #[serde(skip_serializing_if = "Option::is_none")]
82 #[serde(default)]
83 pub session_start_time: Option<String>,
84}
85
86// SAFETY: session_sample_rate is always clamped to [0.0, 1.0] and is never
87// NaN, so the derived PartialEq (f64::eq) satisfies the Eq contract.
88impl Eq for SessionMetadata {}
89
90/// Describes a single event's relationship to the current session.
91///
92/// Computed once in `EventMetric::record_sync` and passed to
93/// `EventDatabase::record`, collapsing the two-phase sampling gate and
94/// metadata-attachment logic into a single value.
95#[derive(Debug, Clone, Serialize, Deserialize, MallocSizeOf)]
96pub enum EventSessionContext {
97 /// The event is out-of-session (always recorded; no session metadata attached).
98 ///
99 /// Covers two cases:
100 /// - The metric was declared `in_session = false`.
101 /// - The metric is session-scoped but no session is currently active
102 /// (between sessions).
103 OutOfSession,
104 /// The event belongs to a sampled-in active session.
105 ///
106 /// The metadata is attached to the resulting `RecordedEvent` for
107 /// downstream session-level analysis.
108 InSession(SessionMetadata),
109}
110
111/// In-memory session state.
112///
113/// All persistence is handled by free functions in this module.
114/// All mutation happens on the Glean dispatcher thread — no internal synchronization needed.
115/// Fields are `pub(crate)` to prevent mutation from outside the crate while still
116/// allowing `core/mod.rs` to drive the session lifecycle directly.
117#[derive(Debug)]
118pub struct SessionManager {
119 /// How sessions are managed.
120 pub(crate) mode: SessionMode,
121 /// Current session state.
122 pub(crate) state: SessionState,
123 /// The current session's UUID, if active.
124 pub(crate) session_id: Option<Uuid>,
125 /// Monotonically increasing session counter (persisted).
126 pub(crate) session_seq: u64,
127 /// Per-session event counter.
128 /// Uses AtomicU64 so `current_metadata_with_next_seq` can be called
129 /// with only `&SessionManager` (via `&Glean`) from `record_sync`.
130 pub(crate) event_seq: AtomicU64,
131 /// The sample rate as originally provided at initialization (0.0–1.0).
132 /// Never mutated after construction; used as the fallback when Remote
133 /// Settings has no active override for the session sample rate.
134 pub(crate) configured_sample_rate: f64,
135 /// The effective sample rate for the *current* session, reflecting any
136 /// Remote Settings override applied at session-start time.
137 /// Written once per session in `session_start()`; read by metadata helpers.
138 pub(crate) sample_rate: f64,
139 /// Whether the current session is sampled in.
140 pub(crate) sampled_in: bool,
141 /// Wall-clock timestamp at session start. `None` between sessions.
142 pub(crate) session_start_time: Option<DateTime<FixedOffset>>,
143 /// When the session went inactive (for AUTO mode timeout evaluation).
144 pub(crate) inactive_since: Option<DateTime<FixedOffset>>,
145 /// How long inactivity before a new session is started (AUTO mode).
146 /// `Duration::ZERO` means sessions never time out (always resumed).
147 pub(crate) inactivity_timeout: Duration,
148}
149
150impl SessionManager {
151 /// Creates a new `SessionManager`.
152 ///
153 /// `sample_rate` is clamped to `[0.0, 1.0]`; values outside that range are
154 /// silently brought to the nearest bound. This matches the behaviour of the
155 /// remote-settings override path so the two paths are always consistent.
156 pub fn new(mode: SessionMode, sample_rate: f64, inactivity_timeout: Duration) -> Self {
157 let clamped = sample_rate.clamp(0.0, 1.0);
158 Self {
159 mode,
160 state: SessionState::Inactive,
161 session_id: None,
162 session_seq: 0,
163 event_seq: AtomicU64::new(0),
164 configured_sample_rate: clamped,
165 sample_rate: clamped,
166 sampled_in: true, // true between sessions so recording proceeds normally
167 session_start_time: None,
168 inactive_since: None,
169 inactivity_timeout,
170 }
171 }
172
173 /// Returns whether the current session is sampled in.
174 ///
175 /// Returns `true` when no session is active (between sessions),
176 /// so telemetry recorded outside of a session is never suppressed.
177 pub fn is_sampled_in(&self) -> bool {
178 match self.state {
179 SessionState::Inactive => true,
180 SessionState::Active => self.sampled_in,
181 }
182 }
183
184 /// Returns whether a session is currently active.
185 pub fn is_active(&self) -> bool {
186 self.state == SessionState::Active
187 }
188
189 /// Returns the current session's UUID, if a session is active.
190 pub fn session_id(&self) -> Option<Uuid> {
191 self.session_id
192 }
193
194 /// Returns whether the current session is sampled in (direct field access).
195 ///
196 /// Differs from `is_sampled_in` in that it returns the raw field value
197 /// without the "inactive → true" override, useful for asserting the exact
198 /// sampling decision made at session start.
199 pub fn sampled_in(&self) -> bool {
200 self.sampled_in
201 }
202
203 /// Returns the wall-clock timestamp recorded when the current session started.
204 pub fn session_start_time(&self) -> Option<DateTime<FixedOffset>> {
205 self.session_start_time
206 }
207
208 /// Returns the current session's metadata without incrementing `event_seq`.
209 pub fn current_metadata(&self) -> Option<SessionMetadata> {
210 if self.state != SessionState::Active {
211 return None;
212 }
213 let id = self.session_id?;
214 Some(SessionMetadata {
215 session_id: id.to_string(),
216 session_seq: self.session_seq,
217 event_seq: self.event_seq.load(Ordering::Relaxed),
218 session_sample_rate: self.sample_rate,
219 session_start_time: self
220 .session_start_time
221 .map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)),
222 })
223 }
224
225 /// Computes the session context (metadata attachment decision) for a single event.
226 ///
227 /// **Precondition:** the caller must have already verified via
228 /// `MetricType::should_record()` that the event should be recorded. That
229 /// check handles sampling suppression for all metric types; this function
230 /// is concerned only with *what context to attach*, not *whether to record*.
231 ///
232 /// Returns `OutOfSession` when no session is active (between sessions), or
233 /// `InSession(meta)` when an active session is present.
234 ///
235 /// `event_seq` is incremented only for `InSession` results so that
236 /// between-session events do not consume sequence numbers.
237 pub fn compute_event_context(&self) -> EventSessionContext {
238 match self.state {
239 SessionState::Inactive => EventSessionContext::OutOfSession,
240 SessionState::Active => {
241 // should_record() has already ensured sampled_in is true, the debug_assert
242 // is for additional safety.
243 debug_assert!(
244 self.sampled_in,
245 "compute_event_context called for unsampled session"
246 );
247 // current_metadata_with_next_seq increments event_seq atomically.
248 match self.current_metadata_with_next_seq() {
249 Some(meta) => EventSessionContext::InSession(meta),
250 // Defensive fallback: session_id was None despite Active state.
251 None => EventSessionContext::OutOfSession,
252 }
253 }
254 }
255 }
256
257 /// Returns the current session's metadata with an atomically incremented `event_seq`.
258 ///
259 /// Called from `EventMetric::record_sync` which only holds `&Glean`.
260 pub fn current_metadata_with_next_seq(&self) -> Option<SessionMetadata> {
261 if self.state != SessionState::Active {
262 return None;
263 }
264 let id = self.session_id?;
265 let seq = self.event_seq.fetch_add(1, Ordering::Relaxed);
266 Some(SessionMetadata {
267 session_id: id.to_string(),
268 session_seq: self.session_seq,
269 event_seq: seq,
270 session_sample_rate: self.sample_rate,
271 session_start_time: self
272 .session_start_time
273 .map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)),
274 })
275 }
276
277 /// Used to reset the in-memory state of the session manager when a session ends.
278 pub fn reset_state(&mut self) {
279 // Update in-memory state.
280 self.state = SessionState::Inactive;
281 self.session_id = None;
282 self.inactive_since = None;
283 self.session_start_time = None;
284 }
285}
286
287// ---------------------------------------------------------------------------
288// Sampling
289// ---------------------------------------------------------------------------
290
291/// Converts a UUID to a deterministic sample value in [0, 1).
292///
293/// Interprets the first 8 bytes as a big-endian u64 and divides by 2^64.
294/// A session is sampled in when `uuid_to_sample_value(uuid) < sample_rate`.
295///
296/// Dividing by 2^64 (not u64::MAX) guarantees the result is strictly less than
297/// 1.0 for all inputs, so `sample_rate = 1.0` always samples every session.
298pub(crate) fn uuid_to_sample_value(uuid: &Uuid) -> f64 {
299 let bytes = uuid.as_bytes();
300 let mut arr = [0u8; 8];
301 arr.copy_from_slice(&bytes[..8]);
302 let n = u64::from_be_bytes(arr);
303 (n as f64) / 2.0f64.powi(64)
304}
305
306// ---------------------------------------------------------------------------
307// Persistence helpers
308//
309// TODO: consider refactoring these to avoid repeated construction of the same metric instances on every read/write.
310// See Bug 2043357
311// ---------------------------------------------------------------------------
312
313fn make_session_seq_metric() -> QuantityMetric {
314 QuantityMetric::new(CommonMetricData {
315 name: SESSION_SEQ_METRIC_NAME.into(),
316 category: String::new(),
317 send_in_pings: vec![INTERNAL_STORAGE.into()],
318 lifetime: Lifetime::User,
319 ..Default::default()
320 })
321}
322
323fn make_session_id_metric() -> StringMetric {
324 StringMetric::new(CommonMetricData {
325 name: SESSION_ID_METRIC_NAME.into(),
326 category: String::new(),
327 send_in_pings: vec![INTERNAL_STORAGE.into()],
328 lifetime: Lifetime::User,
329 ..Default::default()
330 })
331}
332
333/// Stores the inactive-since timestamp as an RFC 3339 string.
334/// An empty string (or absence of the key) means no recorded inactive_since.
335fn make_inactive_since_metric() -> StringMetric {
336 StringMetric::new(CommonMetricData {
337 name: SESSION_INACTIVE_SINCE_METRIC_NAME.into(),
338 category: String::new(),
339 send_in_pings: vec![INTERNAL_STORAGE.into()],
340 lifetime: Lifetime::User,
341 ..Default::default()
342 })
343}
344
345/// Reads the current session sequence number from storage.
346pub(crate) fn read_session_seq(glean: &Glean) -> u64 {
347 make_session_seq_metric()
348 .get_value(glean, INTERNAL_STORAGE)
349 .filter(|&v| v >= 0)
350 .map(|v| v as u64)
351 .unwrap_or(0)
352}
353
354/// Clears all persisted session state (session ID, inactive_since, session
355/// start time, event sequence).
356pub(crate) fn clear(glean: &Glean) {
357 clear_session_id(glean);
358 clear_inactive_since(glean);
359 clear_session_start_time(glean);
360 clear_session_event_seq(glean);
361}
362
363/// Persists the given session sequence number.
364///
365/// `QuantityMetric` stores `i64`; the cast from `u64` is lossless for any
366/// value below `i64::MAX` (~9.2 × 10^18). Values at or above that threshold
367/// (unreachable in practice) would silently wrap, which is preferable to
368/// a panic or corrupted sequence.
369pub(crate) fn store_session_seq(glean: &Glean, seq: u64) {
370 make_session_seq_metric().set_sync(glean, seq as i64);
371}
372
373/// Persists the current session ID.
374/// Pass an empty string to indicate no active session.
375pub(crate) fn persist_session_id(glean: &Glean, id: &str) {
376 make_session_id_metric().set_sync(glean, id);
377}
378
379/// Clears the persisted session ID.
380pub(crate) fn clear_session_id(glean: &Glean) {
381 make_session_id_metric().set_sync(glean, "");
382}
383
384/// Reads the persisted session ID, if any.
385/// Returns `None` if no session ID is stored or if it was cleared.
386pub(crate) fn read_session_id(glean: &Glean) -> Option<String> {
387 let id = make_session_id_metric().get_value(glean, INTERNAL_STORAGE)?;
388 if id.is_empty() {
389 None
390 } else {
391 Some(id)
392 }
393}
394
395/// Persists the inactive-since timestamp as an RFC 3339 string.
396pub(crate) fn persist_inactive_since(glean: &Glean, ts: DateTime<FixedOffset>) {
397 make_inactive_since_metric().set_sync(
398 glean,
399 ts.to_rfc3339_opts(SecondsFormat::Millis, true).as_str(),
400 );
401}
402
403/// Reads the persisted inactive-since timestamp, if any.
404/// Returns `None` if the key is absent or the stored string is empty.
405pub(crate) fn read_inactive_since(glean: &Glean) -> Option<DateTime<FixedOffset>> {
406 let s = make_inactive_since_metric().get_value(glean, INTERNAL_STORAGE)?;
407 if s.is_empty() {
408 return None;
409 }
410 DateTime::parse_from_rfc3339(&s).ok()
411}
412
413/// Clears the inactive-since timestamp by writing an empty string.
414pub(crate) fn clear_inactive_since(glean: &Glean) {
415 make_inactive_since_metric().set_sync(glean, "");
416}
417
418// ---------------------------------------------------------------------------
419// session_start_time persistence
420// ---------------------------------------------------------------------------
421
422fn make_session_start_time_metric() -> StringMetric {
423 StringMetric::new(CommonMetricData {
424 name: SESSION_START_TIME_METRIC_NAME.into(),
425 category: String::new(),
426 send_in_pings: vec![INTERNAL_STORAGE.into()],
427 lifetime: Lifetime::User,
428 ..Default::default()
429 })
430}
431
432/// Persists the session start timestamp as an RFC 3339 string.
433pub(crate) fn persist_session_start_time(glean: &Glean, ts: DateTime<FixedOffset>) {
434 make_session_start_time_metric().set_sync(
435 glean,
436 ts.to_rfc3339_opts(SecondsFormat::Millis, true).as_str(),
437 );
438}
439
440/// Reads the persisted session start timestamp, if any.
441/// Returns `None` if the key is absent, empty, or unparseable.
442pub(crate) fn read_session_start_time(glean: &Glean) -> Option<DateTime<FixedOffset>> {
443 let s = make_session_start_time_metric().get_value(glean, INTERNAL_STORAGE)?;
444 if s.is_empty() {
445 return None;
446 }
447 DateTime::parse_from_rfc3339(&s).ok()
448}
449
450/// Clears the persisted session start timestamp.
451pub(crate) fn clear_session_start_time(glean: &Glean) {
452 make_session_start_time_metric().set_sync(glean, "");
453}
454
455// ---------------------------------------------------------------------------
456// session_event_seq persistence
457// ---------------------------------------------------------------------------
458
459fn make_session_event_seq_metric() -> QuantityMetric {
460 QuantityMetric::new(CommonMetricData {
461 name: SESSION_EVENT_SEQ_METRIC_NAME.into(),
462 category: String::new(),
463 send_in_pings: vec![INTERNAL_STORAGE.into()],
464 lifetime: Lifetime::User,
465 ..Default::default()
466 })
467}
468
469/// Reads the persisted per-session event sequence counter.
470///
471/// Returns `0` if no value has been stored (e.g. fresh session or after clear).
472pub(crate) fn read_session_event_seq(glean: &Glean) -> u64 {
473 make_session_event_seq_metric()
474 .get_value(glean, INTERNAL_STORAGE)
475 .filter(|&v| v >= 0)
476 .map(|v| v as u64)
477 .unwrap_or(0)
478}
479
480/// Persists the per-session event sequence counter.
481///
482/// Should be called whenever the in-memory `event_seq` changes and persistence
483/// is required (i.e. on `session_transition_to_inactive`). The cast from
484/// `u64` is lossless for any value below `i64::MAX`.
485pub(crate) fn store_session_event_seq(glean: &Glean, seq: u64) {
486 make_session_event_seq_metric().set_sync(glean, seq as i64);
487}
488
489/// Clears the persisted event sequence counter (stores 0).
490///
491/// Called when a session ends so a resumed session from a stale storage entry
492/// does not inherit a stale counter.
493pub(crate) fn clear_session_event_seq(glean: &Glean) {
494 make_session_event_seq_metric().set_sync(glean, 0);
495}