diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d77e8fe03..7bab43d410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ [Full changelog](https://github.com/mozilla/glean/compare/v67.2.0...main) +* General + * Add first-class Sessions support: configurable session management (`Auto`, `Lifecycle`, `Manual` modes), session-level sampling, `glean.session_start`/`glean.session_end` boundary events in the `events` ping, and per-event session metadata for downstream analysis ([bug 2020962](https://bugzilla.mozilla.org/show_bug.cgi?id=2020962)) * Rust * Add support for determining `client_info.os_version` for Android binaries ([#3434](https://github.com/mozilla/glean/pull/3434)) diff --git a/docs/user/user/pings/events.md b/docs/user/user/pings/events.md index e71cc5a20f..4343af0691 100644 --- a/docs/user/user/pings/events.md +++ b/docs/user/user/pings/events.md @@ -44,6 +44,8 @@ At the top-level, this ping contains the following keys: - `ping_info`: The information [common to all pings](index.md#the-ping_info-section). - `events`: An array of all of the events that have occurred since the last time the `events` ping was sent. + Glean also emits internal `glean.session_start` and `glean.session_end` boundary events into this array to delimit + sessions; these appear alongside application-recorded events. Each entry in the `events` array is an object with the following properties: diff --git a/glean-core/android/src/main/java/mozilla/telemetry/glean/Glean.kt b/glean-core/android/src/main/java/mozilla/telemetry/glean/Glean.kt index bd8a36e7e9..1d72189152 100644 --- a/glean-core/android/src/main/java/mozilla/telemetry/glean/Glean.kt +++ b/glean-core/android/src/main/java/mozilla/telemetry/glean/Glean.kt @@ -270,6 +270,9 @@ open class GleanInternalAPI internal constructor() { pingSchedule = configuration.pingSchedule, pingLifetimeThreshold = configuration.pingLifetimeThreshold.toULong(), pingLifetimeMaxTime = configuration.pingLifetimeMaxTime.toULong(), + sessionMode = configuration.sessionMode, + sessionSampleRate = configuration.sessionSampleRate, + sessionInactivityTimeoutMs = configuration.sessionInactivityTimeoutMs.toULong(), ) val clientInfo = getClientInfo(configuration, buildInfo) val callbacks = OnGleanEventsImpl(this@GleanInternalAPI) @@ -460,6 +463,30 @@ open class GleanInternalAPI internal constructor() { */ internal fun getDataDir(): File = this.gleanDataDir + /** + * Starts a session manually. + * + * Only has an effect when Glean is configured with [SessionMode.MANUAL]. + * In `AUTO` or `LIFECYCLE` mode this is a no-op so automatic session + * state isn't corrupted. + */ + fun sessionStart() { + gleanSessionStart() + } + + /** + * Ends a session manually. + * + * Only has an effect when Glean is configured with [SessionMode.MANUAL]. + * + * @param reason An optional application-provided string attached to the + * `glean.session_end` boundary event for downstream analysis. + */ + @JvmOverloads + fun sessionEnd(reason: String? = null) { + gleanSessionEnd(reason) + } + /** * Handle the foreground event and send the appropriate pings. */ diff --git a/glean-core/android/src/main/java/mozilla/telemetry/glean/config/Configuration.kt b/glean-core/android/src/main/java/mozilla/telemetry/glean/config/Configuration.kt index fc1d0ea04c..fb23f5d3be 100644 --- a/glean-core/android/src/main/java/mozilla/telemetry/glean/config/Configuration.kt +++ b/glean-core/android/src/main/java/mozilla/telemetry/glean/config/Configuration.kt @@ -5,6 +5,7 @@ package mozilla.telemetry.glean.config import mozilla.telemetry.glean.internal.LevelFilter +import mozilla.telemetry.glean.internal.SessionMode import mozilla.telemetry.glean.net.HttpURLConnectionUploader import mozilla.telemetry.glean.net.PingUploader @@ -30,6 +31,10 @@ import mozilla.telemetry.glean.net.PingUploader * @property pingSchedule A ping schedule map. * Maps a ping name to a list of pings to schedule along with it. * Only used if the ping's own ping schedule list is empty. + * @property sessionMode How Glean manages session boundaries. Default: [SessionMode.AUTO]. + * @property sessionSampleRate Session sampling rate (0.0–1.0). Default: `1.0`. + * @property sessionInactivityTimeoutMs Inactivity timeout (milliseconds) before AUTO-mode + * sessions expire. Default: 30 minutes. */ data class Configuration @JvmOverloads @@ -50,11 +55,19 @@ data class Configuration val pingLifetimeThreshold: Int = 1000, val pingLifetimeMaxTime: Int = 0, val pingSchedule: Map> = emptyMap(), + val sessionMode: SessionMode = SessionMode.AUTO, + val sessionSampleRate: Double = 1.0, + val sessionInactivityTimeoutMs: Long = DEFAULT_SESSION_INACTIVITY_TIMEOUT_MS, ) { companion object { /** * The default server pings are sent to. */ const val DEFAULT_TELEMETRY_ENDPOINT = "https://incoming.telemetry.mozilla.org" + + /** + * The default AUTO-mode session inactivity timeout: 30 minutes. + */ + const val DEFAULT_SESSION_INACTIVITY_TIMEOUT_MS: Long = 1_800_000L } } diff --git a/glean-core/android/src/test/java/mozilla/telemetry/glean/GleanTest.kt b/glean-core/android/src/test/java/mozilla/telemetry/glean/GleanTest.kt index 5408a20a63..e232ab86a1 100644 --- a/glean-core/android/src/test/java/mozilla/telemetry/glean/GleanTest.kt +++ b/glean-core/android/src/test/java/mozilla/telemetry/glean/GleanTest.kt @@ -250,7 +250,8 @@ class GleanTest { checkPingSchema(json) if (docType == "events") { assertEquals("inactive", json.getJSONObject("ping_info").getString("reason")) - assertEquals(1, json.getJSONArray("events").length()) + // 2 events: glean.session_start (on foreground) + ui.click (recorded explicitly) + assertEquals(2, json.getJSONArray("events").length()) } else if (docType == "baseline") { val seq = json.getJSONObject("ping_info").getInt("seq") @@ -314,8 +315,14 @@ class GleanTest { // Trigger worker task to upload the pings in the background triggerWorkManager(context) + // Session recovery emits a session_end event for the previous abnormal session, + // which is flushed as an events ping before the dirty_startup baseline ping. var request = server.takeRequest(20L, TimeUnit.SECONDS)!! var docType = request.path!!.split("/")[3] + assertEquals("The first ping must be the session-recovery 'events' ping", "events", docType) + + request = server.takeRequest(20L, TimeUnit.SECONDS)!! + docType = request.path!!.split("/")[3] assertEquals("The received ping must be a 'baseline' ping", "baseline", docType) var baselineJson = JSONObject(request.getPlainBody()) diff --git a/glean-core/benchmark/benches/dispatcher.rs b/glean-core/benchmark/benches/dispatcher.rs index f881c2807b..f5ecfe709d 100644 --- a/glean-core/benchmark/benches/dispatcher.rs +++ b/glean-core/benchmark/benches/dispatcher.rs @@ -83,6 +83,9 @@ pub fn metric_dispatcher_benchmark(c: &mut Criterion) { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let client_info = ClientInfoMetrics::unknown(); diff --git a/glean-core/benchmark/benches/lifetime_buffering.rs b/glean-core/benchmark/benches/lifetime_buffering.rs index 41ff44a431..077669fc10 100644 --- a/glean-core/benchmark/benches/lifetime_buffering.rs +++ b/glean-core/benchmark/benches/lifetime_buffering.rs @@ -31,6 +31,9 @@ pub fn delay_io_benchmark(c: &mut Criterion) { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let glean = Glean::new(cfg).unwrap(); @@ -73,6 +76,9 @@ pub fn delay_io_benchmark(c: &mut Criterion) { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let glean = Glean::new(cfg).unwrap(); @@ -115,6 +121,9 @@ pub fn delay_io_benchmark(c: &mut Criterion) { ping_schedule: Default::default(), ping_lifetime_threshold: 1000, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let glean = Glean::new(cfg).unwrap(); diff --git a/glean-core/examples/rkv-open.rs b/glean-core/examples/rkv-open.rs index 617f2ac55d..38bc175a38 100644 --- a/glean-core/examples/rkv-open.rs +++ b/glean-core/examples/rkv-open.rs @@ -51,6 +51,9 @@ fn main() { ping_schedule: HashMap::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let client_info = ClientInfoMetrics::unknown(); diff --git a/glean-core/ios/Glean/Config/Configuration.swift b/glean-core/ios/Glean/Config/Configuration.swift index ed656d3e6d..d73ba3bb0d 100644 --- a/glean-core/ios/Glean/Config/Configuration.swift +++ b/glean-core/ios/Glean/Config/Configuration.swift @@ -16,6 +16,9 @@ public struct Configuration { let pingLifetimeThreshold: Int let pingLifetimeMaxTime: Int let pingSchedule: [String: [String]] + let sessionMode: SessionMode + let sessionSampleRate: Double + let sessionInactivityTimeoutMs: UInt64 let httpClient: PingUploader struct Constants { @@ -42,6 +45,10 @@ public struct Configuration { /// * pingSchedule A ping schedule map. /// Maps a ping name to a list of pings to schedule along with it. /// Only used if the ping's own ping schedule list is empty. + /// * sessionMode How Glean manages session boundaries. Default: `.auto`. + /// * sessionSampleRate Session sampling rate (0.0–1.0). Default: `1.0`. + /// * sessionInactivityTimeoutMs Inactivity timeout (ms) before AUTO-mode + /// sessions expire. Default: 30 minutes (1,800,000 ms). /// * httpClient An http uploader that supports the `PingUploader` protocol public init( maxEvents: Int32? = nil, @@ -55,6 +62,9 @@ public struct Configuration { pingLifetimeThreshold: Int = 0, pingLifetimeMaxTime: Int = 0, pingSchedule: [String: [String]] = [:], + sessionMode: SessionMode = .auto, + sessionSampleRate: Double = 1.0, + sessionInactivityTimeoutMs: UInt64 = 1_800_000, httpClient: PingUploader = HttpPingUploader() ) { self.serverEndpoint = @@ -69,6 +79,9 @@ public struct Configuration { self.pingLifetimeThreshold = pingLifetimeThreshold self.pingLifetimeMaxTime = pingLifetimeMaxTime self.pingSchedule = pingSchedule + self.sessionMode = sessionMode + self.sessionSampleRate = sessionSampleRate + self.sessionInactivityTimeoutMs = sessionInactivityTimeoutMs self.httpClient = httpClient } } diff --git a/glean-core/ios/Glean/Glean.swift b/glean-core/ios/Glean/Glean.swift index a98f67e87b..c24d46fe9a 100644 --- a/glean-core/ios/Glean/Glean.swift +++ b/glean-core/ios/Glean/Glean.swift @@ -212,7 +212,10 @@ public final class Glean: @unchecked Sendable { enableInternalPings: configuration.enableInternalPings, pingSchedule: configuration.pingSchedule, pingLifetimeThreshold: UInt64(configuration.pingLifetimeThreshold), - pingLifetimeMaxTime: UInt64(configuration.pingLifetimeMaxTime) + pingLifetimeMaxTime: UInt64(configuration.pingLifetimeMaxTime), + sessionMode: configuration.sessionMode, + sessionSampleRate: configuration.sessionSampleRate, + sessionInactivityTimeoutMs: configuration.sessionInactivityTimeoutMs ) let clientInfo = getClientInfo(configuration, buildInfo: buildInfo) let callbacks = OnGleanEventsImpl(glean: self) @@ -350,6 +353,26 @@ public final class Glean: @unchecked Sendable { return self.initialized } + /// Starts a session manually. + /// + /// Only has an effect when Glean is configured with `SessionMode.manual`. + /// In `.auto` or `.lifecycle` mode this is a no-op so automatic session + /// state isn't corrupted. + public func sessionStart() { + gleanSessionStart() + } + + /// Ends a session manually. + /// + /// Only has an effect when Glean is configured with `SessionMode.manual`. + /// + /// - parameters: + /// * reason: An optional application-provided string attached to the + /// `glean.session_end` boundary event for downstream analysis. + public func sessionEnd(reason: String? = nil) { + gleanSessionEnd(reason) + } + /// Handle foreground event and submit appropriate pings func handleForegroundEvent() { if !isActive { diff --git a/glean-core/ios/GleanTests/GleanTests.swift b/glean-core/ios/GleanTests/GleanTests.swift index 3110eba453..cfb8035f4d 100644 --- a/glean-core/ios/GleanTests/GleanTests.swift +++ b/glean-core/ios/GleanTests/GleanTests.swift @@ -239,8 +239,9 @@ class GleanTests: XCTestCase { // We expect only a single ping later stubServerReceive { pingType, _ in - if pingType == "baseline" { - // Ignore initial "active" baseline ping + if pingType == "baseline" || pingType == "events" { + // Ignore initial "active" baseline ping and events pings + // (session boundary events are now flushed on startup). return } @@ -381,8 +382,9 @@ class GleanTests: XCTestCase { // We expect 10 pings later stubServerReceive { pingType, _ in - if pingType == "baseline" { - // Ignore initial "active" baseline ping + if pingType == "baseline" || pingType == "events" { + // Ignore initial "active" baseline ping and events pings + // (session boundary events are now flushed on startup). return } diff --git a/glean-core/ios/GleanTests/Metrics/EventMetricTests.swift b/glean-core/ios/GleanTests/Metrics/EventMetricTests.swift index ae9e61d1f8..b6a400a296 100644 --- a/glean-core/ios/GleanTests/Metrics/EventMetricTests.swift +++ b/glean-core/ios/GleanTests/Metrics/EventMetricTests.swift @@ -255,6 +255,12 @@ class EventMetricTypeTests: XCTestCase { XCTAssertEqual(1, snapshot3.count) } + /// Filters out internal Glean session boundary events (category == "glean") + /// so tests can check only user-recorded events. + private func userEvents(from events: [Any]?) -> [[String: Any]] { + return (events as? [[String: Any]])?.filter { ($0["category"] as? String) != "glean" } ?? [] + } + func testFlushQueuedEventsOnStartup() { setupHttpResponseStub() expectation = expectation(description: "Completed upload") @@ -277,7 +283,10 @@ class EventMetricTypeTests: XCTestCase { let events = lastPingJson?["events"] as? [Any] XCTAssertNotNil(events) - XCTAssertEqual(1, events?.count) + // Session boundary events (glean.session_start/session_end) are also + // flushed on startup; filter to user-recorded events only. + let userEvts = userEvents(from: events) + XCTAssertEqual(1, userEvts.count) } private func getExtraValue(from event: Any?, for key: String) -> String { @@ -314,8 +323,10 @@ class EventMetricTypeTests: XCTestCase { let events = lastPingJson?["events"] as? [Any] XCTAssertNotNil(events) - XCTAssertEqual(1, events?.count) - XCTAssertEqual("run1", getExtraValue(from: events![0], for: "some_extra")) + // Session boundary events are also flushed on startup; filter to user events. + let userEvts = userEvents(from: events) + XCTAssertEqual(1, userEvts.count) + XCTAssertEqual("run1", getExtraValue(from: userEvts[0], for: "some_extra")) setupHttpResponseStub() expectation = expectation(description: "Completed upload") @@ -328,9 +339,11 @@ class EventMetricTypeTests: XCTestCase { let events2 = lastPingJson?["events"] as? [Any] XCTAssertNotNil(events2) - XCTAssertEqual(2, events2?.count) - XCTAssertEqual("pre-init", getExtraValue(from: events2![0], for: "some_extra")) - XCTAssertEqual("post-init", getExtraValue(from: events2![1], for: "some_extra")) + // Session boundary events are also present; filter to user events. + let userEvts2 = userEvents(from: events2) + XCTAssertEqual(2, userEvts2.count) + XCTAssertEqual("pre-init", getExtraValue(from: userEvts2[0], for: "some_extra")) + XCTAssertEqual("post-init", getExtraValue(from: userEvts2[1], for: "some_extra")) } func testEventLongExtraRecordsError() { diff --git a/glean-core/ios/GleanTests/Net/BaselinePingTests.swift b/glean-core/ios/GleanTests/Net/BaselinePingTests.swift index 301aca0125..2f76ff5318 100644 --- a/glean-core/ios/GleanTests/Net/BaselinePingTests.swift +++ b/glean-core/ios/GleanTests/Net/BaselinePingTests.swift @@ -143,6 +143,12 @@ final class BaselinePingTests: XCTestCase { // Set up the test stub based on the default telemetry endpoint stubServerReceive { pingType, json in + // Skip events pings: session boundary events (session_start/session_end) + // are now recorded to the "events" ping and may be flushed on startup. + if pingType == "events" { + return + } + XCTAssertEqual("baseline", pingType) XCTAssert(json != nil) diff --git a/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift b/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift index 40212b3728..aacdce7bd8 100644 --- a/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift +++ b/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift @@ -259,8 +259,9 @@ class MetricsPingSchedulerTests: XCTestCase { // Set up the interception of the ping for inspection stubServerReceive { pingType, json in - if pingType == "baseline" { - // Ignore initial "active" baseline ping + if pingType == "baseline" || pingType == "events" { + // Ignore initial "active" baseline ping and events pings + // (session boundary events are now flushed on startup). return } diff --git a/glean-core/metrics.yaml b/glean-core/metrics.yaml index 0f4b8aac5a..4ff3308e61 100644 --- a/glean-core/metrics.yaml +++ b/glean-core/metrics.yaml @@ -1068,6 +1068,100 @@ glean: - glean-team@mozilla.com expires: never + session_start: + type: event + description: | + Recorded when a new session starts. + + Always emitted regardless of session sampling so analysts can + observe all session boundaries and validate sampling rates. + send_in_pings: + - events + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_sensitivity: + - technical + notification_emails: + - glean-team@mozilla.com + expires: never + extra_keys: + session_id: + description: The unique UUID of the started session. + type: string + session_seq: + description: The session sequence number (monotonically increasing across restarts). + type: quantity + session_start_time: + description: | + Wall-clock timestamp at session start (RFC 3339). + Auxiliary only; per-event timestamps remain authoritative for + time-based analysis. + type: string + sampled_in: + description: | + Whether this session is sampled in (true) or sampled out (false). + Events from sampled-out sessions are suppressed; this field lets + analysts validate that the observed sampling rate matches the + configured rate. + type: boolean + + session_end: + type: event + description: | + Recorded when a session ends. + + Always emitted regardless of session sampling. The `reason` extra key + describes why the session ended. + send_in_pings: + - events + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_sensitivity: + - technical + notification_emails: + - glean-team@mozilla.com + expires: never + extra_keys: + session_id: + description: The unique UUID of the ended session. + type: string + session_seq: + description: The session sequence number (monotonically increasing across restarts). + type: quantity + reason: + description: | + Why the session ended. Possible values: + - "inactive": client went to background (LIFECYCLE mode) + - "replaced": a new session was started while one was already active + - "timeout": AUTO mode inactivity timeout expired + - "abnormal": session ended due to app crash/force-close + - "abnormal_inactive": session was inactive at time of crash + type: string + + sessions_seen: + type: counter + description: | + The total number of sessions started during this period. + Incremented for every session start, regardless of sampling. + This metric is out-of-session so it is never suppressed by any + session sampling so that it can be used to validate sampling + rates against the events-ping session_start count. + send_in_pings: + - health + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1960592 + data_sensitivity: + - technical + notification_emails: + - glean-team@mozilla.com + expires: never + glean.ping: uploader_capabilities: type: string_list diff --git a/glean-core/python/glean/__init__.py b/glean-core/python/glean/__init__.py index fa526d4b57..5ff8a40892 100644 --- a/glean-core/python/glean/__init__.py +++ b/glean-core/python/glean/__init__.py @@ -17,6 +17,7 @@ from .glean import Glean from .config import Configuration from ._loader import load_metrics, load_pings +from ._uniffi import SessionMode __version__: str = "unknown" @@ -52,6 +53,7 @@ "__version__", "Glean", "Configuration", + "SessionMode", "load_metrics", "load_pings", ] diff --git a/glean-core/python/glean/config.py b/glean-core/python/glean/config.py index b11fb8625e..9f4e4594b6 100644 --- a/glean-core/python/glean/config.py +++ b/glean-core/python/glean/config.py @@ -10,6 +10,7 @@ from . import net +from ._uniffi import SessionMode # The default server pings are sent to @@ -20,6 +21,10 @@ DEFAULT_MAX_EVENTS = 500 +# The default inactivity timeout (milliseconds) for AUTO-mode sessions: 30 minutes. +DEFAULT_SESSION_INACTIVITY_TIMEOUT_MS = 1_800_000 + + class Configuration: """ Configuration values for Glean. @@ -35,6 +40,9 @@ def __init__( enable_event_timestamps: bool = True, experimentation_id: Optional[str] = None, enable_internal_pings: bool = True, + session_mode: SessionMode = SessionMode.AUTO, + session_sample_rate: float = 1.0, + session_inactivity_timeout_ms: int = DEFAULT_SESSION_INACTIVITY_TIMEOUT_MS, ): """ Args: @@ -53,6 +61,12 @@ def __init__( experimentation_id (string): An experimentation identifier derived by the application to be sent with all pings. Default: None. enable_internal_pings (bool): Whether to enable internal pings. Default: `True`. + session_mode (SessionMode): How Glean manages session boundaries. + Default: `SessionMode.AUTO`. + session_sample_rate (float): Session sampling rate (0.0–1.0). + Default: `1.0`. + session_inactivity_timeout_ms (int): Inactivity timeout (milliseconds) + before AUTO-mode sessions expire. Default: 30 minutes. """ if server_endpoint is None: server_endpoint = DEFAULT_TELEMETRY_ENDPOINT @@ -66,6 +80,9 @@ def __init__( self._enable_event_timestamps = enable_event_timestamps self._experimentation_id = experimentation_id self._enable_internal_pings = enable_internal_pings + self._session_mode = session_mode + self._session_sample_rate = session_sample_rate + self._session_inactivity_timeout_ms = session_inactivity_timeout_ms @property def server_endpoint(self) -> str: @@ -120,5 +137,20 @@ def ping_uploader(self) -> net.BaseUploader: def ping_uploader(self, value: net.BaseUploader): self._ping_uploader = value + @property + def session_mode(self) -> SessionMode: + """How Glean manages session boundaries.""" + return self._session_mode + + @property + def session_sample_rate(self) -> float: + """Session sampling rate (0.0–1.0).""" + return self._session_sample_rate + + @property + def session_inactivity_timeout_ms(self) -> int: + """Inactivity timeout (milliseconds) before AUTO-mode sessions expire.""" + return self._session_inactivity_timeout_ms + __all__ = ["Configuration"] diff --git a/glean-core/python/glean/glean.py b/glean-core/python/glean/glean.py index 0d994eea61..d18dbca2bf 100644 --- a/glean-core/python/glean/glean.py +++ b/glean-core/python/glean/glean.py @@ -238,6 +238,9 @@ def initialize( ping_schedule={}, ping_lifetime_threshold=0, ping_lifetime_max_time=0, + session_mode=configuration.session_mode, + session_sample_rate=configuration.session_sample_rate, + session_inactivity_timeout_ms=configuration.session_inactivity_timeout_ms, ) _uniffi.glean_initialize(cfg, client_info, callbacks) @@ -482,6 +485,30 @@ def handle_client_inactive(cls): """ _uniffi.glean_handle_client_inactive() + @classmethod + def session_start(cls): + """ + Starts a session manually. + + Only has an effect when Glean is configured with `SessionMode.MANUAL`. + In `AUTO` or `LIFECYCLE` mode this is a no-op so automatic session + state isn't corrupted. + """ + _uniffi.glean_session_start() + + @classmethod + def session_end(cls, reason: Optional[str] = None): + """ + Ends a session manually. + + Only has an effect when Glean is configured with `SessionMode.MANUAL`. + + Args: + reason (str): Optional application-provided string attached to the + `glean.session_end` boundary event for downstream analysis. + """ + _uniffi.glean_session_end(reason) + @classmethod def shutdown(cls): """ diff --git a/glean-core/python/glean/net/ping_upload_worker.py b/glean-core/python/glean/net/ping_upload_worker.py index b1086d9201..fee60d9a65 100644 --- a/glean-core/python/glean/net/ping_upload_worker.py +++ b/glean-core/python/glean/net/ping_upload_worker.py @@ -16,7 +16,13 @@ glean_initialize_for_subprocess, glean_process_ping_upload_response, ) -from .._uniffi import InternalConfiguration, UploadTaskAction, PingUploadTask, PingRequest +from .._uniffi import ( + InternalConfiguration, + SessionMode, + UploadTaskAction, + PingUploadTask, + PingRequest, +) from .._process_dispatcher import ProcessDispatcher @@ -122,6 +128,9 @@ def _process(data_dir: Path, application_id: str, configuration) -> bool: ping_schedule={}, ping_lifetime_threshold=0, ping_lifetime_max_time=0, + session_mode=SessionMode.AUTO, + session_sample_rate=1.0, + session_inactivity_timeout_ms=1800000, ) if not glean_initialize_for_subprocess(cfg): log.error("Couldn't initialize Glean in subprocess") diff --git a/glean-core/python/glean/testing/__init__.py b/glean-core/python/glean/testing/__init__.py index b83fa18f58..e701bbefee 100644 --- a/glean-core/python/glean/testing/__init__.py +++ b/glean-core/python/glean/testing/__init__.py @@ -100,7 +100,10 @@ def do_upload( uncompressed_data = gzip.decompress(data) if is_gzipped else data - if self.filter_string in path: + filters = ( + self.filter_string if isinstance(self.filter_string, list) else [self.filter_string] + ) + if any(f in path for f in filters): self._filtered_pings.append((str(path), uncompressed_data.decode("utf-8"))) return UploadResult.HTTP_STATUS(200) diff --git a/glean-core/python/tests/test_glean.py b/glean-core/python/tests/test_glean.py index 65013eab23..e6f967ec77 100644 --- a/glean-core/python/tests/test_glean.py +++ b/glean-core/python/tests/test_glean.py @@ -827,7 +827,10 @@ def test_client_activity_api(tmpdir, monkeypatch, helpers): # real-world async behaviour of this. configuration = Glean._configuration - configuration.ping_uploader = _RecordingUploader(info_path) + # Filter events pings: session_start events are now recorded on + # handle_client_active(), causing an events ping on handle_client_inactive() + # which would overwrite the baseline ping in the single-file recorder. + configuration.ping_uploader = _RecordingUploader(info_path, filter_string=["health", "events"]) Glean._testing_mode = False glean_set_test_mode(False) @@ -844,8 +847,8 @@ def test_client_activity_api(tmpdir, monkeypatch, helpers): url_path, payload = helpers.wait_for_ping(info_path) assert "baseline" == url_path.split("/")[3] assert payload["ping_info"]["reason"] == "active" - # It's an empty ping. - assert "metrics" not in payload + # No duration is recorded on an active ping. + assert "timespan" not in payload.get("metrics", {}) # The upload process is fast, but not fast enough to communicate its status. # We give it just a blink of an eye to wind down. @@ -867,7 +870,7 @@ def test_client_activity_api(tmpdir, monkeypatch, helpers): url_path, payload = helpers.wait_for_ping(info_path) assert "baseline" == url_path.split("/")[3] assert payload["ping_info"]["reason"] == "active" - assert "timespan" not in payload["metrics"] + assert "timespan" not in payload.get("metrics", {}) def test_sending_of_custom_pings(safe_httpserver): diff --git a/glean-core/rlb/src/configuration.rs b/glean-core/rlb/src/configuration.rs index 12220009d3..049b21fb3d 100644 --- a/glean-core/rlb/src/configuration.rs +++ b/glean-core/rlb/src/configuration.rs @@ -5,6 +5,7 @@ use log::LevelFilter; use crate::net::PingUploader; +use glean_core::SessionMode; use std::collections::HashMap; use std::path::PathBuf; @@ -58,6 +59,12 @@ pub struct Configuration { pub ping_lifetime_threshold: usize, /// After what time to auto-flush. 0 disables it. pub ping_lifetime_max_time: Duration, + /// Session management mode. Default: `Auto`. + pub session_mode: SessionMode, + /// Session sampling rate (0.0–1.0). Default: `1.0`. + pub session_sample_rate: f64, + /// Inactivity timeout for AUTO mode sessions. Default: 30 minutes. + pub session_inactivity_timeout: Duration, } /// Configuration builder. @@ -114,6 +121,12 @@ pub struct Builder { pub ping_lifetime_threshold: usize, /// After what time to auto-flush. 0 disables it. pub ping_lifetime_max_time: Duration, + /// Session management mode. Default: `Auto`. + pub session_mode: SessionMode, + /// Session sampling rate (0.0–1.0). Default: `1.0`. + pub session_sample_rate: f64, + /// Inactivity timeout for AUTO mode sessions. Default: 30 minutes. + pub session_inactivity_timeout: Duration, } impl Builder { @@ -141,6 +154,9 @@ impl Builder { ping_schedule: HashMap::new(), ping_lifetime_threshold: 0, ping_lifetime_max_time: Duration::ZERO, + session_mode: SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout: Duration::from_secs(30 * 60), } } @@ -164,9 +180,30 @@ impl Builder { ping_schedule: self.ping_schedule, ping_lifetime_threshold: self.ping_lifetime_threshold, ping_lifetime_max_time: self.ping_lifetime_max_time, + session_mode: self.session_mode, + session_sample_rate: self.session_sample_rate, + session_inactivity_timeout: self.session_inactivity_timeout, } } + /// Set the session management mode. + pub fn with_session_mode(mut self, mode: SessionMode) -> Self { + self.session_mode = mode; + self + } + + /// Set the session sampling rate (0.0–1.0). + pub fn with_session_sample_rate(mut self, rate: f64) -> Self { + self.session_sample_rate = rate; + self + } + + /// Set the inactivity timeout for AUTO mode session boundaries. + pub fn with_session_inactivity_timeout(mut self, timeout: Duration) -> Self { + self.session_inactivity_timeout = timeout; + self + } + /// Set the maximum number of events to store before sending a ping containing events. pub fn with_max_events(mut self, max_events: usize) -> Self { self.max_events = Some(max_events); diff --git a/glean-core/rlb/src/lib.rs b/glean-core/rlb/src/lib.rs index b802ce8754..717885eeb9 100644 --- a/glean-core/rlb/src/lib.rs +++ b/glean-core/rlb/src/lib.rs @@ -129,6 +129,9 @@ fn initialize_internal(cfg: Configuration, client_info: ClientInfoMetrics) -> Op ping_schedule: cfg.ping_schedule, ping_lifetime_threshold: cfg.ping_lifetime_threshold as u64, ping_lifetime_max_time: cfg.ping_lifetime_max_time.as_millis() as u64, + session_mode: cfg.session_mode, + session_sample_rate: cfg.session_sample_rate, + session_inactivity_timeout_ms: cfg.session_inactivity_timeout.as_millis() as u64, }; glean_core::glean_initialize(core_cfg, client_info.into(), callbacks); @@ -140,6 +143,22 @@ pub fn shutdown() { glean_core::shutdown() } +/// Starts a session manually (MANUAL mode only). +/// +/// In `SessionMode::Manual`, the application is responsible for calling +/// `session_start` and `session_end` to manage session boundaries. +pub fn session_start() { + glean_core::glean_session_start(); +} + +/// Ends a session manually (MANUAL mode only). +/// +/// `reason` is an optional application-provided string attached to the +/// `glean.session_end` boundary event for downstream analysis. +pub fn session_end(reason: Option) { + glean_core::glean_session_end(reason); +} + /// **DEPRECATED** Sets whether upload is enabled or not. /// /// **DEPRECATION NOTICE**: diff --git a/glean-core/rlb/src/test.rs b/glean-core/rlb/src/test.rs index 76e88fd678..be337a0ac2 100644 --- a/glean-core/rlb/src/test.rs +++ b/glean-core/rlb/src/test.rs @@ -143,6 +143,7 @@ fn disabling_upload_disables_metrics_recording() { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + ..Default::default() }); crate::set_upload_enabled(false); @@ -438,6 +439,7 @@ fn queued_recorded_metrics_correctly_record_during_init() { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + ..Default::default() }); // This will queue 3 tasks that will add to the metric value once Glean is initialized @@ -1259,6 +1261,7 @@ fn test_a_ping_before_submission() { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + ..Default::default() }); metric.add(1); @@ -1289,6 +1292,7 @@ fn test_boolean_get_num_errors() { lifetime: Lifetime::Application, disabled: false, dynamic_label: Some(DynamicLabelType::Label(str::to_string("asdf"))), + ..Default::default() }); // Check specifically for an invalid label @@ -1375,6 +1379,7 @@ fn test_text_can_hold_long_string() { lifetime: Lifetime::Application, disabled: false, dynamic_label: Some(DynamicLabelType::Label(str::to_string("text"))), + ..Default::default() }); // 216 characters, which would overflow StringMetric diff --git a/glean-core/rlb/tests/upload_timing.rs b/glean-core/rlb/tests/upload_timing.rs index 6fa77cbaa0..657f87f568 100644 --- a/glean-core/rlb/tests/upload_timing.rs +++ b/glean-core/rlb/tests/upload_timing.rs @@ -56,6 +56,7 @@ pub mod metrics { lifetime: Lifetime::Ping, disabled: false, dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ) @@ -71,6 +72,7 @@ pub mod metrics { lifetime: Lifetime::Ping, disabled: false, dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ) @@ -86,6 +88,7 @@ pub mod metrics { lifetime: Lifetime::Ping, disabled: false, dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ) diff --git a/glean-core/src/common_metric_data.rs b/glean-core/src/common_metric_data.rs index 6e9c1dc596..76a2eef257 100644 --- a/glean-core/src/common_metric_data.rs +++ b/glean-core/src/common_metric_data.rs @@ -54,7 +54,7 @@ impl TryFrom for Lifetime { } /// The common set of data shared across all different metric types. -#[derive(Default, Debug, Clone, Deserialize, Serialize, MallocSizeOf)] +#[derive(Debug, Clone, Deserialize, Serialize, MallocSizeOf, Default)] pub struct CommonMetricData { /// The metric's name. pub name: String, @@ -75,6 +75,13 @@ pub struct CommonMetricData { /// label so that we can validate them when the Glean singleton is /// available. pub dynamic_label: Option, + /// Whether this metric is inside of the session scope. + /// + /// `false` (the default) means this metric bypasses session sampling and + /// does not carry session metadata. Non-event metrics should use the default + /// for now. Event metrics that participate in session tracking must + /// explicitly set this to `true`. + pub in_session: bool, } /// The type of dynamic label applied to a base metric. Used to help identify diff --git a/glean-core/src/core/mod.rs b/glean-core/src/core/mod.rs index e38798ffb4..712b6bc7aa 100644 --- a/glean-core/src/core/mod.rs +++ b/glean-core/src/core/mod.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use chrono::{DateTime, FixedOffset}; +use chrono::{DateTime, FixedOffset, SecondsFormat}; use malloc_size_of_derive::MallocSizeOf; use once_cell::sync::OnceCell; use uuid::Uuid; @@ -27,6 +27,7 @@ use crate::metrics::{ self, ExperimentMetric, Metric, MetricType, PingType, RecordedExperiment, RemoteSettingsConfig, }; use crate::ping::PingMaker; +use crate::session::{self, EventSessionContext, SessionManager, SessionMode, SessionState}; use crate::storage::{StorageManager, INTERNAL_STORAGE}; use crate::upload::{PingUploadManager, PingUploadTask, UploadResult, UploadTaskAction}; use crate::util::{local_now_with_offset, sanitize_application_id}; @@ -139,6 +140,9 @@ where /// ping_schedule: Default::default(), /// ping_lifetime_threshold: 1000, /// ping_lifetime_max_time: 2000, +/// session_mode: glean_core::SessionMode::Auto, +/// session_sample_rate: 1.0, +/// session_inactivity_timeout_ms: 1_800_000, /// }; /// let mut glean = Glean::new(cfg).unwrap(); /// let ping = PingType::new("sample", true, false, true, true, true, vec![], vec![], true, vec![]); @@ -186,6 +190,8 @@ pub struct Glean { pub(crate) remote_settings_config: Arc>, pub(crate) with_timestamps: bool, pub(crate) ping_schedule: HashMap>, + #[ignore_malloc_size_of = "TODO: Expose session memory allocations (bug 1960592)"] + pub(crate) session_manager: SessionManager, } impl Glean { @@ -248,6 +254,18 @@ impl Glean { remote_settings_config: Arc::new(Mutex::new(RemoteSettingsConfig::new())), with_timestamps: cfg.enable_event_timestamps, ping_schedule: cfg.ping_schedule.clone(), + // The SessionManager is deliberately left in its default (hollow) + // state for subprocesses. `restore_session_state_from_storage()` + // is only called in `Glean::new()`, not here, so the subprocess + // never loads or mutates the main process's persisted session + // state. This prevents subprocesses from interfering with the + // main process's session lifecycle (seq counters, dirty flags, + // boundary events, etc.). + session_manager: SessionManager::new( + cfg.session_mode, + cfg.session_sample_rate, + std::time::Duration::from_millis(cfg.session_inactivity_timeout_ms), + ), }; // Ensuring these pings are registered. @@ -281,6 +299,8 @@ impl Glean { ping_lifetime_max_time, )?); + glean.restore_session_state_from_storage(); + // This code references different states from the "Client ID recovery" flowchart. // See https://mozilla.github.io/glean/dev/core/internal/client_id_recovery.html for details. @@ -540,6 +560,9 @@ impl Glean { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let mut glean = Self::new(cfg).unwrap(); @@ -924,6 +947,11 @@ impl Glean { &self.event_data_store } + /// Gets a reference to the session manager. + pub fn session_manager(&self) -> &SessionManager { + &self.session_manager + } + pub(crate) fn with_timestamps(&self) -> bool { self.with_timestamps } @@ -1137,6 +1165,25 @@ impl Glean { remote_settings_config.event_threshold = cfg.event_threshold; + // Clamp to [0.0, 1.0] so callers can't accidentally set an invalid rate. + // + // NOTE: `session_sample_rate` is intentionally NOT applied to any + // currently-active session. The override is picked up at the next + // `session_start()` call. This "sticky per session" design means: + // - A mid-session RS rollout does not change sampling mid-flight, + // which would otherwise cause partial session data. + // - To clear the override and revert to the configured rate, set + // `session_sample_rate` to `null` in the RS payload. The next + // session will use `configured_sample_rate` as the fallback. + // + // This override is intentionally NOT persisted to storage. Remote + // Settings configuration is refreshed on every app startup, so the + // override will be re-applied before the next session begins. + // Persisting it would risk making a stale value sticky if the RS + // payload changes or is removed between restarts. + remote_settings_config.session_sample_rate = + cfg.session_sample_rate.map(|r| r.clamp(0.0, 1.0)); + // Store the Server Knobs configuration as an ObjectMetric // Since RemoteSettingsConfig only contains maps with string keys and primitives, // serialization via the derived Serialize impl cannot fail so it is safe to unwrap. @@ -1300,11 +1347,406 @@ impl Glean { } } + // ----------------------------------------------------------------------- + // Session lifecycle methods + // ----------------------------------------------------------------------- + + /// Restores session state from persistent storage at startup. + /// + /// Must be called after `data_store` is initialized (i.e. after + /// `Database::new` succeeds) so that the storage reads are valid. + /// + /// **Sequence counter**: `session_seq` is always restored so it is + /// monotonically increasing across restarts. Note that if a crash occurs + /// between `store_session_seq` and `persist_session_id` inside + /// `session_start`, the sequence number will have been incremented but no + /// session ID will be persisted. On the next restart this method will + /// restore the incremented seq and the next session will be assigned + /// seq+1, leaving a one-element gap. This is acceptable — downstream + /// analysts should treat sequence numbers as monotonically non-decreasing, + /// not strictly contiguous. + /// + /// **AUTO mode resumption**: requires both a persisted `session_id` **and** + /// an `inactive_since` timestamp. If either is absent the previous session + /// is considered abandoned and the next `handle_client_active` call will + /// start a fresh session via `session_start()`. On a crash restart, + /// `recover_session_on_dirty_flag()` overwrites whatever this method + /// restores, so the dirty-flag path is always authoritative. + fn restore_session_state_from_storage(&mut self) { + // Always restore seq so new sessions increment from the last known value. + self.session_manager.session_seq = session::read_session_seq(self); + + // Check for an orphaned session from a previous build that used a + // different SessionMode. If the current mode would not restore the + // persisted session, emit a synthetic session_end("abandoned") and + // clear all persisted session state so it doesn't leak across builds. + if self.session_manager.mode != SessionMode::Auto { + if let Some(id_str) = session::read_session_id(self) { + log::info!( + "Orphaned session {} found from a previous Auto-mode build; \ + emitting session_end(\"abandoned\") and clearing storage", + id_str + ); + let seq = self.session_manager.session_seq; + self.record_session_end_event(&id_str, seq, Some("abandoned")); + session::clear_session_id(self); + session::clear_inactive_since(self); + session::clear_session_start_time(self); + session::clear_session_event_seq(self); + } + return; + } + + // AUTO mode: restore inactive session state so inactivity timeout + // evaluation can happen lazily on the next handle_client_active call. + if let Some(inactive_since) = session::read_inactive_since(self) { + if let Some(id_str) = session::read_session_id(self) { + if let Ok(id) = Uuid::parse_str(&id_str) { + // Recompute sampled_in deterministically from the UUID so + // the sampling decision is consistent across the resumed session. + let sampled_in = session::uuid_to_sample_value(&id) + < self.session_manager.configured_sample_rate; + self.session_manager.session_id = Some(id); + self.session_manager.inactive_since = Some(inactive_since); + self.session_manager.sampled_in = sampled_in; + self.session_manager.session_start_time = + session::read_session_start_time(self); + if self.session_manager.session_start_time.is_none() { + log::warn!( + "Resumed session {} has no persisted session_start_time; \ + events in this session will carry session_start_time: null", + id + ); + } + // Restore event_seq so the resumed session issues + // monotonically increasing sequence numbers even across + // a clean restart. + self.session_manager + .event_seq + .store(session::read_session_event_seq(self), Ordering::Relaxed); + self.session_manager.state = SessionState::Inactive; + } + } + } + } + + /// Injects a `glean_timestamp` key into `extra` when event timestamps are enabled. + /// + /// Takes the already-computed `timestamp_ms` so the glean_timestamp extra and + /// the event's main timestamp are both derived from the same clock sample. + fn maybe_inject_glean_timestamp( + &self, + extra: &mut std::collections::HashMap, + timestamp_ms: u64, + ) { + if self.with_timestamps { + extra.insert("glean_timestamp".to_string(), timestamp_ms.to_string()); + } + } + + /// Records a `glean.session_start` boundary event (always, regardless of sampling). + fn record_session_start_event( + &self, + session_id: &str, + seq: u64, + start_time: DateTime, + sampled_in: bool, + ) { + let meta = CommonMetricData { + name: "session_start".into(), + category: "glean".into(), + send_in_pings: vec!["events".into()], + lifetime: Lifetime::Ping, + ..Default::default() + }; + let timestamp = crate::get_timestamp_ms(); + let mut extra = std::collections::HashMap::new(); + extra.insert("session_id".to_string(), session_id.to_string()); + extra.insert("session_seq".to_string(), seq.to_string()); + extra.insert( + "session_start_time".to_string(), + start_time.to_rfc3339_opts(SecondsFormat::Millis, true), + ); + extra.insert("sampled_in".to_string(), sampled_in.to_string()); + self.maybe_inject_glean_timestamp(&mut extra, timestamp); + self.event_data_store.record( + self, + &meta.into(), + timestamp, + Some(extra), + EventSessionContext::OutOfSession, + ); + } + + /// Records a `glean.session_end` boundary event (always, regardless of sampling). + fn record_session_end_event(&self, session_id: &str, seq: u64, reason: Option<&str>) { + let meta = CommonMetricData { + name: "session_end".into(), + category: "glean".into(), + send_in_pings: vec!["events".into()], + lifetime: Lifetime::Ping, + ..Default::default() + }; + let timestamp = crate::get_timestamp_ms(); + let mut extra = std::collections::HashMap::new(); + extra.insert("session_id".to_string(), session_id.to_string()); + extra.insert("session_seq".to_string(), seq.to_string()); + if let Some(r) = reason { + extra.insert("reason".to_string(), r.to_string()); + } + self.maybe_inject_glean_timestamp(&mut extra, timestamp); + self.event_data_store.record( + self, + &meta.into(), + timestamp, + Some(extra), + EventSessionContext::OutOfSession, + ); + } + + /// Starts a new session, persists state, and records a boundary event. + /// + /// If a session is already active it is ended cleanly before the new one + /// starts, preventing orphaned sessions with no corresponding `session_end`. + pub fn session_start(&mut self) { + // End any already-active session so we never orphan a session_end event. + if self.session_manager.is_active() { + self.session_end(Some("replaced")); + } + + // 1. Compute new seq from in-memory value (authoritative after init). + let new_seq = self.session_manager.session_seq + 1; + + // 2. Generate new session_id and compute sampling. + // Prefer a remote-settings override if one has been set, falling back + // to the immutable configured_sample_rate (never the last effective + // rate) so RS overrides can be fully cleared without residual effects. + // The rate is sampled once here and is sticky for the entire session; + // any RS update received mid-session takes effect at the next session_start. + let session_id = uuid::Uuid::new_v4(); + let sample_rate = { + let remote = self.remote_settings_config.lock().unwrap(); + remote + .session_sample_rate + .unwrap_or(self.session_manager.configured_sample_rate) + }; + let sampled_in = session::uuid_to_sample_value(&session_id) < sample_rate; + + // 3. Update in-memory state. + self.session_manager.sample_rate = sample_rate; + // Truncate to millisecond precision so that in-memory and persisted + // (RFC 3339 millis) representations are identical after a round-trip. + let start_time = { + let now = local_now_with_offset(); + let millis = now.timestamp_millis(); + DateTime::from_timestamp_millis(millis) + .expect("valid timestamp") + .with_timezone(now.offset()) + }; + self.session_manager.session_start_time = Some(start_time); + self.session_manager.session_id = Some(session_id); + self.session_manager.session_seq = new_seq; + self.session_manager.event_seq.store(0, Ordering::Relaxed); + self.session_manager.sampled_in = sampled_in; + self.session_manager.state = SessionState::Active; + self.session_manager.inactive_since = None; + + // 4. Persist to storage. + session::store_session_seq(self, new_seq); + session::persist_session_id(self, &session_id.to_string()); + session::persist_session_start_time(self, start_time); + session::clear_inactive_since(self); + + // 5. Increment diagnostic counter. + self.additional_metrics.sessions_seen.add_sync(self, 1); + + // 6. Record boundary event. + self.record_session_start_event(&session_id.to_string(), new_seq, start_time, sampled_in); + } + + /// Ends the current session, persists state, and records a boundary event. + /// + /// Returns the ended session's metadata, or `None` if no session was active. + pub fn session_end(&mut self, reason: Option<&str>) -> Option { + if self.session_manager.state != SessionState::Active { + return None; + } + + let session_id = self.session_manager.session_id?; + let seq = self.session_manager.session_seq; + let event_seq = self.session_manager.event_seq.load(Ordering::Relaxed); + let sample_rate = self.session_manager.sample_rate; + let start_time = self.session_manager.session_start_time; + + // Update in-memory state. + self.session_manager.state = SessionState::Inactive; + self.session_manager.session_id = None; + self.session_manager.inactive_since = None; + self.session_manager.session_start_time = None; + + // Clear persistence. + session::clear_session_id(self); + session::clear_inactive_since(self); + session::clear_session_start_time(self); + session::clear_session_event_seq(self); + + // Record boundary event. + self.record_session_end_event(&session_id.to_string(), seq, reason); + + Some(crate::session::SessionMetadata { + session_id: session_id.to_string(), + session_seq: seq, + event_seq, + session_sample_rate: sample_rate, + session_start_time: start_time.map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)), + }) + } + + /// Transitions the current session to inactive (AUTO mode). + /// + /// Records the `inactive_since` timestamp for timeout evaluation on next activation. + /// Does NOT end the session — that happens lazily on next `handle_client_active`. + pub(crate) fn session_transition_to_inactive(&mut self) { + if self.session_manager.state != SessionState::Active { + return; + } + + let now = local_now_with_offset(); + // Snapshot event_seq before changing state so the value is stable. + let event_seq = self.session_manager.event_seq.load(Ordering::Relaxed); + self.session_manager.state = SessionState::Inactive; + self.session_manager.inactive_since = Some(now); + + // Persist for crash recovery and clean-restart resumption. + // event_seq is persisted here (rather than on every increment) because + // this is the only point where events stop being recorded mid-session; + // if the app crashes before the next activation, the recovered session + // will at least have the correct seq baseline from the last inactive + // transition. + session::persist_inactive_since(self, now); + session::store_session_event_seq(self, event_seq); + } + + /// Handles transitioning from inactive to active (AUTO mode). + /// + /// Evaluates the inactivity timeout: + /// - If the timeout has NOT expired: resume the existing session. + /// - If the timeout HAS expired: end the old session and start a new one. + /// + /// Returns `true` if a new session was started. + pub(crate) fn session_transition_to_active(&mut self) -> bool { + match self.session_manager.inactive_since { + None => { + // No inactive_since recorded: treat as a cold activation and start + // a fresh session. The call site in handle_client_active guards + // with `inactive_since.is_some()` so this is normally unreachable, + // but we handle it safely rather than leaving state inconsistent. + self.session_start(); + true + } + Some(inactive_since) => { + let now = local_now_with_offset(); + let elapsed = (now - inactive_since).to_std().unwrap_or_default(); + + // A timeout of zero means "never time out" (session always resumes). + if !self.session_manager.inactivity_timeout.is_zero() + && elapsed >= self.session_manager.inactivity_timeout + { + // Timeout expired → end old session (emits boundary event), start new one. + // The session state was set to Inactive by session_transition_to_inactive(), + // but session_id is still set. Restore Active so session_end() can proceed. + self.session_manager.state = SessionState::Active; + self.session_end(Some("timeout")); + self.session_start(); + true + } else { + // Timeout has NOT expired → resume existing session. + self.session_manager.state = SessionState::Active; + self.session_manager.inactive_since = None; + session::clear_inactive_since(self); + false + } + } + } + } + + /// Called during initialization to recover an abnormally terminated session. + /// + /// If the dirty flag was set and a session ID is persisted, emits a synthetic + /// `session_end` event with reason "abnormal" and clears session state. + pub(crate) fn recover_session_on_dirty_flag(&mut self) { + let persisted_id = match session::read_session_id(self) { + Some(id) => id, + None => return, // No previous session to recover. + }; + + let persisted_seq = self.session_manager.session_seq; + let inactive_since = session::read_inactive_since(self); + + // Determine if the session ended while inactive (timeout may have expired). + let reason = if inactive_since.is_some() { + "abnormal_inactive" + } else { + "abnormal" + }; + + log::info!( + "Recovering abnormally terminated session: {} (seq={})", + persisted_id, + persisted_seq + ); + + // Emit synthetic session_end. + self.record_session_end_event(&persisted_id, persisted_seq, Some(reason)); + + // Clear persisted session state so the recovered session won't be replayed. + session::clear_session_id(self); + session::clear_inactive_since(self); + session::clear_session_start_time(self); + session::clear_session_event_seq(self); + + // Reset in-memory state so the next session_start gets a clean slate. + self.session_manager.state = SessionState::Inactive; + self.session_manager.session_id = None; + self.session_manager.inactive_since = None; + self.session_manager.session_start_time = None; + } + + // ----------------------------------------------------------------------- + // Client lifecycle methods + // ----------------------------------------------------------------------- + /// Performs the collection/cleanup operations required by becoming active. /// /// This functions generates a baseline ping with reason `active` /// and then sets the dirty bit. pub fn handle_client_active(&mut self) { + // Session lifecycle (mode-dependent). + match self.session_manager.mode { + SessionMode::Auto => { + if !self.session_manager.is_active() { + if self.session_manager.inactive_since.is_some() { + // Was inactive — evaluate timeout. + self.session_transition_to_active(); + } else { + // First activation — start initial session. + self.session_start(); + } + } + } + SessionMode::Lifecycle => { + // Only start a session on the first activation following an inactive + // transition. Guard against duplicate handle_client_active calls which + // are not a real lifecycle transition. + if !self.session_manager.is_active() { + self.session_start(); + } + } + SessionMode::Manual => { + // No automatic session management. + } + } + if !self .internal_pings .baseline @@ -1321,6 +1763,22 @@ impl Glean { /// This functions generates a baseline and an events ping with reason /// `inactive` and then clears the dirty bit. pub fn handle_client_inactive(&mut self) { + // Session lifecycle (mode-dependent). + match self.session_manager.mode { + SessionMode::Auto => { + // In AUTO mode, don't end the session immediately. Instead record + // inactive_since for lazy timeout evaluation on next activation. + self.session_transition_to_inactive(); + } + SessionMode::Lifecycle => { + // End session immediately on going inactive. + self.session_end(Some("inactive")); + } + SessionMode::Manual => { + // No automatic session management. + } + } + if !self .internal_pings .baseline diff --git a/glean-core/src/event_database/mod.rs b/glean-core/src/event_database/mod.rs index bbfb498b9c..8039f8fd28 100644 --- a/glean-core/src/event_database/mod.rs +++ b/glean-core/src/event_database/mod.rs @@ -23,6 +23,7 @@ use serde_json::{json, Value as JsonValue}; use crate::common_metric_data::CommonMetricDataInternal; use crate::error_recording::{record_error, ErrorType}; use crate::metrics::{DatetimeMetric, TimeUnit}; +use crate::session::{EventSessionContext, SessionMetadata}; use crate::storage::INTERNAL_STORAGE; use crate::util::get_iso_time_string; use crate::Glean; @@ -53,6 +54,14 @@ pub struct RecordedEvent { /// The set of allowed extra keys is defined by users in the metrics file. #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option>, + + /// Session metadata attached to this event. + /// + /// `None` for out-of-session events and events recorded before + /// sessions were introduced (backwards compatibility). + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub session: Option, } /// Represents the stored data for a single event. @@ -220,6 +229,7 @@ impl EventDatabase { &glean_restarted.into(), crate::get_timestamp_ms(), Some(extra), + EventSessionContext::OutOfSession, ); } has_events_events && glean.submit_ping_by_name("events", Some("startup")) @@ -284,6 +294,8 @@ impl EventDatabase { /// monotonically increasing timer (this value is obtained on the /// platform-specific side). /// * `extra` - Extra data values, mapping strings to strings. + /// * `ctx` - The event's session context, conveying both whether session + /// metadata should be attached and what that metadata is. /// /// ## Returns /// @@ -295,12 +307,19 @@ impl EventDatabase { meta: &CommonMetricDataInternal, timestamp: u64, extra: Option>, + ctx: EventSessionContext, ) -> bool { // If upload is disabled we don't want to record. if !glean.is_upload_enabled() { return false; } + // Convert the session context to the optional metadata stored on the event. + let session = match ctx { + EventSessionContext::OutOfSession => None, + EventSessionContext::InSession(session_meta) => Some(session_meta), + }; + let mut submit_max_capacity_event_ping = false; { let mut db = self.event_stores.write().unwrap(); // safe unwrap, only error case is poisoning @@ -325,6 +344,7 @@ impl EventDatabase { category: meta.inner.category.to_string(), name: meta.inner.name.to_string(), extra: extra.clone(), + session: session.clone(), }, execution_counter, }; @@ -737,6 +757,7 @@ mod test { category: "cat".to_string(), name: "name".to_string(), extra: None, + session: None, }; let mut data = HashMap::new(); @@ -746,6 +767,7 @@ mod test { category: "cat".to_string(), name: "name".to_string(), extra: Some(data), + session: None, }; let event_empty_json = ::serde_json::to_string_pretty(&event_empty).unwrap(); @@ -793,6 +815,7 @@ mod test { category: "cat".to_string(), name: "name".to_string(), extra: None, + session: None, }; let mut data = HashMap::new(); @@ -802,6 +825,7 @@ mod test { category: "cat".to_string(), name: "name".to_string(), extra: Some(data), + session: None, }; assert_eq!( @@ -835,11 +859,18 @@ mod test { category: test_category.to_string(), name: test_name.to_string(), extra: None, + session: None, }; // Upload is not yet disabled, // so let's check that everything is getting recorded as expected. - db.record(&glean, &test_meta, 2, None); + db.record( + &glean, + &test_meta, + 2, + None, + EventSessionContext::OutOfSession, + ); { let event_stores = db.event_stores.read().unwrap(); assert_eq!( @@ -855,7 +886,13 @@ mod test { glean.set_upload_enabled(false); // Now that upload is disabled, let's check nothing is recorded. - db.record(&glean, &test_meta, 2, None); + db.record( + &glean, + &test_meta, + 2, + None, + EventSessionContext::OutOfSession, + ); { let event_stores = db.event_stores.read().unwrap(); assert_eq!(event_stores.get(test_storage).unwrap().len(), 1); @@ -874,6 +911,7 @@ mod test { category: "glean".into(), name: "restarted".into(), extra: None, + session: None, }, execution_counter: None, }; @@ -914,6 +952,7 @@ mod test { category: "glean".into(), name: "restarted".into(), extra: None, + session: None, }, execution_counter: None, }; @@ -923,6 +962,7 @@ mod test { category: "category".into(), name: "name".into(), extra: None, + session: None, }, execution_counter: None, }; @@ -963,6 +1003,7 @@ mod test { category: "glean".into(), name: "restarted".into(), extra: None, + session: None, }, execution_counter: None, }; @@ -973,6 +1014,7 @@ mod test { category: "category".into(), name: "name".into(), extra: None, + session: None, }, execution_counter: None, }; @@ -1298,6 +1340,7 @@ mod test { category: "glean".into(), name: "restarted".into(), extra: None, + session: None, }, execution_counter: Some(2), }; @@ -1307,6 +1350,7 @@ mod test { category: "category".into(), name: "name".into(), extra: None, + session: None, }, execution_counter: Some(2), }; @@ -1316,6 +1360,7 @@ mod test { category: "glean".into(), name: "restarted".into(), extra: None, + session: None, }, execution_counter: Some(3), }; diff --git a/glean-core/src/glean.udl b/glean-core/src/glean.udl index f63c714088..6cfc683501 100644 --- a/glean-core/src/glean.udl +++ b/glean-core/src/glean.udl @@ -61,6 +61,10 @@ namespace glean { void glean_handle_client_active(); void glean_handle_client_inactive(); + // Manual session management API (only has effect in SessionMode::Manual). + void glean_session_start(); + void glean_session_end(optional string? reason = null); + void glean_submit_ping_by_name(string ping_name, optional string? reason = null); boolean glean_submit_ping_by_name_sync(string ping_name, optional string? reason = null); @@ -111,6 +115,19 @@ dictionary InternalConfiguration { record> ping_schedule; u64 ping_lifetime_threshold; u64 ping_lifetime_max_time; // in millis + SessionMode session_mode; + f64 session_sample_rate; // Must be in [0.0, 1.0]; values outside are clamped. + u64 session_inactivity_timeout_ms; // Milliseconds; 0 means sessions never time out. +}; + +// Session management mode. +enum SessionMode { + // Glean manages sessions automatically based on client activity and inactivity timeout. + "Auto", + // A new session starts on every client-active/inactive lifecycle transition. + "Lifecycle", + // Sessions are managed manually by the application. + "Manual", }; // How to specify the rate pings may be uploaded before they are throttled. @@ -379,6 +396,14 @@ dictionary CommonMetricData { // dynamic labels are stored in the specific label so that // we can validate them when the Glean singleton is available. DynamicLabelType? dynamic_label = null; + + // Whether this metric is inside of the session scope. + // + // Out-of-session metrics bypass session sampling and do not carry + // session metadata. Currently defaults to false (out-of-session) for all + // metric types except event metrics. Events will be generated by + // glean_parser to override this to true (in-session) by default. + boolean in_session = false; }; interface CounterMetric { @@ -645,6 +670,21 @@ interface DatetimeMetric { i32 test_get_num_recorded_errors(ErrorType error); }; +// Session metadata attached to each in-session event. +dictionary SessionMetadata { + // The unique UUID for this session. + string session_id; + // Monotonically increasing session counter (persisted across restarts). + u64 session_seq; + // Per-session event counter (reset each session). + u64 event_seq; + // The sampling rate in effect for this session. + f64 session_sample_rate; + // Wall-clock timestamp at session start (RFC 3339). + // Null on events from before this field was introduced. + string? session_start_time = null; +}; + // Represents the recorded data for a single event. dictionary RecordedEvent { // The timestamp of when the event was recorded. @@ -666,6 +706,10 @@ dictionary RecordedEvent { // // The set of allowed extra keys is defined by users in the metrics file. record? extra; + + // Session metadata for this event. + // Null for out-of-session events and events from before sessions were introduced. + SessionMetadata? session = null; }; interface EventMetric { diff --git a/glean-core/src/internal_metrics.rs b/glean-core/src/internal_metrics.rs index fd46c642b1..295dd3dbb0 100644 --- a/glean-core/src/internal_metrics.rs +++ b/glean-core/src/internal_metrics.rs @@ -47,6 +47,9 @@ pub struct AdditionalMetrics { /// Server knobs configuration received from remote settings. pub server_knobs_config: ObjectMetric, + + /// The total number of sessions started, regardless of sampling outcome. + pub sessions_seen: CounterMetric, } impl CoreMetrics { @@ -57,8 +60,7 @@ impl CoreMetrics { category: "".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), first_run_date: DatetimeMetric::new( @@ -67,8 +69,7 @@ impl CoreMetrics { category: "".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }, TimeUnit::Day, ), @@ -78,8 +79,7 @@ impl CoreMetrics { category: "".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::Application, - disabled: false, - dynamic_label: None, + ..Default::default() }), attribution_source: StringMetric::new(CommonMetricData { @@ -87,8 +87,7 @@ impl CoreMetrics { category: "attribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), attribution_medium: StringMetric::new(CommonMetricData { @@ -96,8 +95,7 @@ impl CoreMetrics { category: "attribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), attribution_campaign: StringMetric::new(CommonMetricData { @@ -105,8 +103,7 @@ impl CoreMetrics { category: "attribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), attribution_term: StringMetric::new(CommonMetricData { @@ -114,8 +111,7 @@ impl CoreMetrics { category: "attribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), attribution_content: StringMetric::new(CommonMetricData { @@ -123,8 +119,7 @@ impl CoreMetrics { category: "attribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), distribution_name: StringMetric::new(CommonMetricData { @@ -132,8 +127,7 @@ impl CoreMetrics { category: "distribution".into(), send_in_pings: vec!["glean_client_info".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), } } @@ -147,8 +141,7 @@ impl AdditionalMetrics { category: "glean.error".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), pings_submitted: LabeledMetric::::new( @@ -158,8 +151,7 @@ impl AdditionalMetrics { category: "glean.validation".into(), send_in_pings: vec!["metrics".into(), "baseline".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, }, None, @@ -171,8 +163,7 @@ impl AdditionalMetrics { category: "glean.validation".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ), @@ -183,8 +174,7 @@ impl AdditionalMetrics { category: "glean.validation".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ), @@ -202,8 +192,7 @@ impl AdditionalMetrics { category: "glean.client.annotation".into(), send_in_pings: vec!["all-pings".into()], lifetime: Lifetime::Application, - disabled: false, - dynamic_label: None, + ..Default::default() }), event_timestamp_clamped: CounterMetric::new(CommonMetricData { @@ -211,8 +200,15 @@ impl AdditionalMetrics { category: "glean.error".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() + }), + + sessions_seen: CounterMetric::new(CommonMetricData { + name: "sessions_seen".into(), + category: "glean".into(), + send_in_pings: vec!["health".into()], + lifetime: Lifetime::Ping, + ..Default::default() }), server_knobs_config: ObjectMetric::new(CommonMetricData { @@ -222,6 +218,7 @@ impl AdditionalMetrics { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + ..Default::default() }), } } @@ -250,8 +247,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, }, Some(vec![ @@ -270,8 +266,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, MemoryUnit::Kilobyte, ), @@ -282,8 +277,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, MemoryUnit::Kilobyte, ), @@ -293,8 +287,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), pending_pings: CounterMetric::new(CommonMetricData { @@ -302,8 +295,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), send_success: TimingDistributionMetric::new( @@ -312,8 +304,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ), @@ -324,8 +315,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, TimeUnit::Millisecond, ), @@ -335,8 +325,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), missing_send_ids: CounterMetric::new(CommonMetricData { @@ -344,8 +333,7 @@ impl UploadMetrics { category: "glean.upload".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), } } @@ -371,8 +359,7 @@ impl DatabaseMetrics { category: "glean.database".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, MemoryUnit::Byte, ), @@ -382,8 +369,7 @@ impl DatabaseMetrics { category: "glean.database".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), write_time: TimingDistributionMetric::new( @@ -393,7 +379,7 @@ impl DatabaseMetrics { send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, disabled: true, - dynamic_label: None, + ..Default::default() }, TimeUnit::Microsecond, ), @@ -449,32 +435,28 @@ impl HealthMetrics { category: "glean.health".into(), send_in_pings: vec!["metrics".into(), "health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), init_count: CounterMetric::new(CommonMetricData { name: "init_count".into(), category: "glean.health".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::User, - disabled: false, - dynamic_label: None, + ..Default::default() }), exception_state: StringMetric::new(CommonMetricData { name: "exception_state".into(), category: "glean.health".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), recovered_client_id: UuidMetric::new(CommonMetricData { name: "recovered_client_id".into(), category: "glean.health".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }), file_read_error: LabeledMetric::::new( LabeledMetricData::Common { @@ -483,8 +465,7 @@ impl HealthMetrics { category: "glean.health".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, }, Some(vec![ @@ -502,8 +483,7 @@ impl HealthMetrics { category: "glean.health".into(), send_in_pings: vec!["health".into()], lifetime: Lifetime::Ping, - disabled: false, - dynamic_label: None, + ..Default::default() }, }, Some(vec![ diff --git a/glean-core/src/lib.rs b/glean-core/src/lib.rs index 7f47159a23..7edd237781 100644 --- a/glean-core/src/lib.rs +++ b/glean-core/src/lib.rs @@ -52,6 +52,7 @@ mod internal_pings; pub mod metrics; pub mod ping; mod scheduler; +pub(crate) mod session; pub mod storage; mod system; #[doc(hidden)] @@ -85,6 +86,7 @@ pub use crate::metrics::{ TestGetValue, TextMetric, TimeUnit, TimerId, TimespanMetric, TimingDistributionMetric, UrlMetric, UuidMetric, }; +pub use crate::session::{SessionManager, SessionMetadata, SessionMode}; pub use crate::upload::{PingRequest, PingUploadTask, UploadResult, UploadTaskAction}; const GLEAN_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -166,6 +168,13 @@ pub struct InternalConfiguration { pub ping_lifetime_threshold: u64, /// After what time to auto-flush. 0 disables it. pub ping_lifetime_max_time: u64, + /// Session management mode. Default: `Auto`. + pub session_mode: session::SessionMode, + /// The fraction of sessions to sample (0.0–1.0). Default: `1.0` (all sessions). + pub session_sample_rate: f64, + /// Inactivity timeout in milliseconds for AUTO mode before a new session starts. + /// Default: 1 800 000 ms (30 minutes). + pub session_inactivity_timeout_ms: u64, } /// How to specify the rate at which pings may be uploaded before they are throttled. @@ -469,6 +478,13 @@ fn initialize_inner( dirty_flag = glean.is_dirty_flag_set(); glean.set_dirty_flag(false); + // Session crash recovery: if the dirty flag was set, the previous + // run ended abnormally. Emit a synthetic session_end for any + // persisted session. + if dirty_flag { + glean.recover_session_on_dirty_flag(); + } + // Perform registration of pings that were attempted to be // registered before init. let pings = PRE_INIT_PING_REGISTRATION.lock().unwrap(); @@ -1196,6 +1212,33 @@ pub fn glean_handle_client_inactive() { }) } +/// Starts a session manually. +/// +/// Only has effect in `SessionMode::Manual`. Calling this in `Auto` or +/// `Lifecycle` mode is a no-op to prevent corrupting automatic session state. +pub fn glean_session_start() { + launch_with_glean_mut(|glean| { + if glean.session_manager.mode == session::SessionMode::Manual { + glean.session_start(); + } + }); +} + +/// Ends a session manually. +/// +/// Only has effect in `SessionMode::Manual`. Calling this in `Auto` or +/// `Lifecycle` mode is a no-op to prevent corrupting automatic session state. +/// +/// `reason` is an optional application-provided string attached to the +/// `glean.session_end` boundary event for downstream analysis. +pub fn glean_session_end(reason: Option) { + launch_with_glean_mut(move |glean| { + if glean.session_manager.mode == session::SessionMode::Manual { + glean.session_end(reason.as_deref()); + } + }); +} + /// Collect and submit a ping for eventual upload by name. pub fn glean_submit_ping_by_name(ping_name: String, reason: Option) { dispatcher::launch(|| { diff --git a/glean-core/src/lib_unit_tests.rs b/glean-core/src/lib_unit_tests.rs index 97df93f2b7..164e1bf03e 100644 --- a/glean-core/src/lib_unit_tests.rs +++ b/glean-core/src/lib_unit_tests.rs @@ -232,6 +232,9 @@ fn experimentation_id_is_set_correctly() { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: crate::session::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }) .unwrap(); @@ -1360,3 +1363,166 @@ fn pings_are_controllable_from_remote_settings_config() { assert!(disabled_ping.submit_sync(&glean, None)); assert!(!enabled_ping.submit_sync(&glean, None)); } + +// --------------------------------------------------------------------------- +// Session crash recovery (requires pub(crate) access) +// --------------------------------------------------------------------------- + +/// Helper: create an EventMetric that matches glean.session_end boundary events. +fn session_end_metric_internal() -> metrics::EventMetric { + metrics::EventMetric::new( + CommonMetricData { + name: "session_end".into(), + category: "glean".into(), + send_in_pings: vec!["events".into()], + lifetime: Lifetime::Ping, + ..Default::default() + }, + vec![], + ) +} + +/// When the dirty flag is set and a session_id is persisted, restarting and +/// calling `recover_session_on_dirty_flag` emits a synthetic session_end +/// with reason="abnormal". +#[test] +fn crash_recovery_emits_abnormal_session_end() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().display().to_string(); + + { + let mut glean = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + // handle_client_active: starts session (persists session_id) and sets dirty flag. + glean.handle_client_active(); + // Drop without calling handle_client_inactive — simulates crash. + } + + // Simulate restart on the same data path. + let mut glean2 = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + assert!( + glean2.is_dirty_flag_set(), + "dirty flag must still be set after crash" + ); + + glean2.recover_session_on_dirty_flag(); + + let events = session_end_metric_internal() + .get_value(&glean2, "events") + .expect("expected synthetic session_end after crash recovery"); + assert_eq!(1, events.len()); + let extra = events[0] + .extra + .as_ref() + .expect("expected extras on session_end"); + assert_eq!( + "abnormal", + extra + .get("reason") + .expect("reason missing from synthetic session_end"), + "crashed active session must produce reason='abnormal'" + ); + assert!( + extra.contains_key("session_id"), + "synthetic session_end must carry the crashed session's session_id" + ); + + // After recovery, session state must be fully cleared. + assert!( + glean2.session_manager().session_id().is_none(), + "session_id must be cleared after crash recovery" + ); + assert!( + !glean2.session_manager().is_active(), + "session must be inactive after crash recovery" + ); +} + +/// After crash recovery, the next session must have session_seq incremented +/// from the crashed session's seq. +#[test] +fn crash_recovery_next_session_increments_seq() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().display().to_string(); + + let crashed_seq; + { + let mut glean = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + glean.handle_client_active(); + crashed_seq = glean.session_manager().session_seq; + // Drop without handle_client_inactive — simulates crash. + } + + let mut glean2 = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + glean2.recover_session_on_dirty_flag(); + + // Start a new session after recovery. + glean2.handle_client_active(); + + assert_eq!( + crashed_seq + 1, + glean2.session_manager().session_seq, + "new session after crash recovery must have session_seq = crashed_seq + 1" + ); +} + +/// When the dirty flag is set, a session_id is persisted, AND an inactive_since +/// timestamp is persisted, crash recovery emits session_end with reason="abnormal_inactive". +#[test] +fn crash_recovery_emits_abnormal_inactive_session_end() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().display().to_string(); + + { + let mut glean = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + glean.handle_client_active(); // dirty=true, session persisted + // Manually persist inactive_since to simulate a crash while inactive + // (dirty flag was set before backgrounding, then the app crashed + // mid-shutdown before clearing it). + let now = chrono::Local::now().fixed_offset(); + crate::session::persist_inactive_since(&glean, now); + // Drop without clearing dirty flag. + } + + let mut glean2 = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + assert!(glean2.is_dirty_flag_set()); + + glean2.recover_session_on_dirty_flag(); + + let events = session_end_metric_internal() + .get_value(&glean2, "events") + .expect("expected synthetic session_end after crash recovery"); + assert_eq!(1, events.len()); + let extra = events[0].extra.as_ref().expect("expected extras"); + assert_eq!( + "abnormal_inactive", + extra.get("reason").expect("reason missing"), + "crashed inactive session must produce reason='abnormal_inactive'" + ); +} + +/// When the dirty flag is set but no session_id is persisted, crash recovery +/// is a no-op (no synthetic session_end emitted). +#[test] +fn crash_recovery_no_session_is_noop() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().display().to_string(); + + { + let glean = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + // Set dirty flag without starting a session. + glean.set_dirty_flag(true); + // Drop. + } + + let mut glean2 = Glean::with_options(&data_path, GLOBAL_APPLICATION_ID, true, true); + assert!(glean2.is_dirty_flag_set()); + + glean2.recover_session_on_dirty_flag(); + + assert!( + session_end_metric_internal() + .get_value(&glean2, "events") + .is_none(), + "crash recovery without a persisted session must not emit any event" + ); +} diff --git a/glean-core/src/metrics/event.rs b/glean-core/src/metrics/event.rs index b788948f3e..75ddd01f5a 100644 --- a/glean-core/src/metrics/event.rs +++ b/glean-core/src/metrics/event.rs @@ -8,6 +8,7 @@ use crate::common_metric_data::CommonMetricDataInternal; use crate::error_recording::{record_error, test_get_num_recorded_errors, ErrorType}; use crate::event_database::RecordedEvent; use crate::metrics::MetricType; +use crate::session::EventSessionContext; use crate::util::truncate_string_at_boundary_with_error; use crate::Glean; use crate::{CommonMetricData, TestGetValue}; @@ -157,9 +158,21 @@ impl EventMetric { map.insert("glean_timestamp".to_string(), precise_timestamp.to_string()); } + // Determine the session context for this event. + // + // Sampling suppression has already been handled by `should_record()` above, + // which gates all metric types uniformly. This call is concerned only with + // what metadata to attach: `OutOfSession` for out-of-session metrics and + // between-session events, `InSession(meta)` for events in a sampled-in session. + let ctx = if !self.meta().inner.in_session { + EventSessionContext::OutOfSession + } else { + glean.session_manager().compute_event_context() + }; + glean .event_storage() - .record(glean, &self.meta, timestamp, extra_strings) + .record(glean, &self.meta, timestamp, extra_strings, ctx) } /// **Test-only API (exported for FFI purposes).** diff --git a/glean-core/src/metrics/mod.rs b/glean-core/src/metrics/mod.rs index a916d017d5..0cca3a5cb2 100644 --- a/glean-core/src/metrics/mod.rs +++ b/glean-core/src/metrics/mod.rs @@ -215,6 +215,28 @@ pub trait MetricType { // at worst we would see that metric enabled/disabled wrongly once. // But since everything is tunneled through the dispatcher, this should never ever happen. + /* + Session sampling gate: suppress in-session telemetry for sampled-out sessions. + + This check applies to ALL metric types, not just events because the `in_session` property + is shared through CommonMetricData. We might want to add session metadata to non-event + metrics in the future, and if we do, they should be suppressed by session sampling just + like events are. + + In-session metrics (`in_session = true`) are suppressed here when the active session is + sampled out. + + Out-of-session metrics (`in_session = false`) bypass this gate entirely and always record. + + EventMetric additionally uses `compute_event_context()` after this check to determine + which session metadata to attach — that function is purely about metadata, not suppression, + and it may be called from other metric types in future if they also need per-event session + context. + */ + if self.meta().inner.in_session && !glean.session_manager().is_sampled_in() { + return false; + } + // Get the current disabled field from the metric metadata, including // the encoded remote_settings epoch let disabled_field = self.meta().disabled.load(Ordering::Relaxed); @@ -257,8 +279,11 @@ pub trait MetricType { let new_disabled = (remote_settings_epoch << 4) | (current_disabled & 0xF); self.meta().disabled.store(new_disabled, Ordering::Relaxed); - // Return a boolean indicating whether or not the metric should be recorded - current_disabled == 0 + if current_disabled != 0 { + return false; + } + + true } } diff --git a/glean-core/src/metrics/remote_settings_config.rs b/glean-core/src/metrics/remote_settings_config.rs index 1c7c4c5c09..b4fa9e3f28 100644 --- a/glean-core/src/metrics/remote_settings_config.rs +++ b/glean-core/src/metrics/remote_settings_config.rs @@ -40,6 +40,12 @@ pub struct RemoteSettingsConfig { /// It overrides the value configured at initialization time. #[serde(default, skip_serializing_if = "Option::is_none")] pub event_threshold: Option, + + /// Remote override for the session sampling rate (0.0–1.0). + /// When set, this overrides the value configured at initialization time. + /// Changes take effect at the next session start. + #[serde(default)] + pub session_sample_rate: Option, } impl RemoteSettingsConfig { diff --git a/glean-core/src/metrics/string.rs b/glean-core/src/metrics/string.rs index 2e33068686..528c682ad9 100644 --- a/glean-core/src/metrics/string.rs +++ b/glean-core/src/metrics/string.rs @@ -168,6 +168,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); let sample_string = "0123456789".repeat(26); diff --git a/glean-core/src/metrics/text.rs b/glean-core/src/metrics/text.rs index ae47fd34bc..12c3c5bd61 100644 --- a/glean-core/src/metrics/text.rs +++ b/glean-core/src/metrics/text.rs @@ -172,6 +172,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); let sample_string = "0123456789".repeat(200 * 1024); diff --git a/glean-core/src/metrics/url.rs b/glean-core/src/metrics/url.rs index d9d0b46525..93c063ed5e 100644 --- a/glean-core/src/metrics/url.rs +++ b/glean-core/src/metrics/url.rs @@ -186,6 +186,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); let sample_url = "glean://test".to_string(); @@ -204,6 +205,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); // Whenever the URL is longer than our MAX_URL_LENGTH, we truncate the URL to the @@ -242,6 +244,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); let test_url = "data:application/json"; @@ -266,6 +269,7 @@ mod test { lifetime: Lifetime::Application, disabled: false, dynamic_label: None, + in_session: false, }); let incorrects = vec![ diff --git a/glean-core/src/ping/mod.rs b/glean-core/src/ping/mod.rs index ce21b26e0d..b182aefc6f 100644 --- a/glean-core/src/ping/mod.rs +++ b/glean-core/src/ping/mod.rs @@ -176,6 +176,12 @@ impl PingMaker { .insert("server_knobs_config".to_string(), server_knobs_config); } + // Session metadata lives on individual event records (RecordedEvent.session), + // not in ping_info. The ping_info schema has additionalProperties: false, so + // adding a session field here would break schema validation. Per-event session + // metadata is the authoritative source per the spec; ping-level session fields + // would require a schema change coordinated across the Glean ecosystem. + map } @@ -566,6 +572,7 @@ mod test { metrics_enabled, pings_enabled, event_threshold: Some(41), + session_sample_rate: None, }; glean.apply_server_knobs_config(config); diff --git a/glean-core/src/session/mod.rs b/glean-core/src/session/mod.rs new file mode 100644 index 0000000000..393606aaa3 --- /dev/null +++ b/glean-core/src/session/mod.rs @@ -0,0 +1,469 @@ +// 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/. + +//! Session management for Glean. +//! +//! Sessions provide first-class boundaries for user activity, enabling +//! session-level sampling, explicit start/end events, and per-event session +//! metadata for downstream analysis. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, FixedOffset, SecondsFormat}; +use malloc_size_of_derive::MallocSizeOf; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::metrics::{QuantityMetric, StringMetric}; +use crate::storage::INTERNAL_STORAGE; +use crate::{CommonMetricData, Glean, Lifetime}; + +// Storage key names for session persistence. +const SESSION_SEQ_METRIC_NAME: &str = "session#seq"; +const SESSION_ID_METRIC_NAME: &str = "session#id"; +const SESSION_INACTIVE_SINCE_METRIC_NAME: &str = "session#inactive_since"; +const SESSION_START_TIME_METRIC_NAME: &str = "session#start_time"; +const SESSION_EVENT_SEQ_METRIC_NAME: &str = "session#event_seq"; + +/// How sessions are managed by Glean. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default, MallocSizeOf)] +pub enum SessionMode { + /// Glean automatically manages session boundaries based on client activity. + /// Sessions end after a configurable inactivity timeout. + #[default] + Auto, + /// A new session starts on every client-active/inactive transition. + Lifecycle, + /// Sessions are managed manually by the application. + /// + /// `handle_client_active` and `handle_client_inactive` have no effect on + /// session state. The application must call `glean_session_start()` and + /// `glean_session_end()` explicitly. + /// + /// Telemetry recorded before the first `glean_session_start()` call is + /// treated as between-session telemetry: it is not suppressed by session + /// sampling and carries no session metadata. + Manual, +} + +/// The state of the current session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + /// No active session. + Inactive, + /// A session is currently active. + Active, +} + +/// Session metadata attached to each in-session event. +/// +/// Serialized into the event payload for downstream session-level analysis. +/// +/// `PartialEq` is derived (using `f64::eq` for `session_sample_rate`). +/// `Eq` is implemented manually — it is sound because `session_sample_rate` +/// is always clamped to `[0.0, 1.0]` and is therefore never NaN. +/// Tests should prefer asserting on individual fields rather than whole-struct +/// equality for clarity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MallocSizeOf)] +pub struct SessionMetadata { + /// The unique UUID for this session. + pub session_id: String, + /// Monotonically increasing session counter, persisted across restarts. + pub session_seq: u64, + /// Per-session event counter, reset at each new session. + pub event_seq: u64, + /// The sampling rate in effect for this session. + pub session_sample_rate: f64, + /// Wall-clock timestamp at session start (RFC 3339). + /// Absent on events from before sessions introduced this field. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub session_start_time: Option, +} + +// SAFETY: session_sample_rate is always clamped to [0.0, 1.0] and is never +// NaN, so the derived PartialEq (f64::eq) satisfies the Eq contract. +impl Eq for SessionMetadata {} + +/// Describes a single event's relationship to the current session. +/// +/// Computed once in `EventMetric::record_sync` and passed to +/// `EventDatabase::record`, collapsing the two-phase sampling gate and +/// metadata-attachment logic into a single value. +#[derive(Debug, Clone, Serialize, Deserialize, MallocSizeOf)] +pub enum EventSessionContext { + /// The event is out-of-session (always recorded; no session metadata attached). + /// + /// Covers two cases: + /// - The metric was declared `in_session = false`. + /// - The metric is session-scoped but no session is currently active + /// (between sessions). + OutOfSession, + /// The event belongs to a sampled-in active session. + /// + /// The metadata is attached to the resulting `RecordedEvent` for + /// downstream session-level analysis. + InSession(SessionMetadata), +} + +/// In-memory session state. +/// +/// All persistence is handled by free functions in this module. +/// All mutation happens on the Glean dispatcher thread — no internal synchronization needed. +/// Fields are `pub(crate)` to prevent mutation from outside the crate while still +/// allowing `core/mod.rs` to drive the session lifecycle directly. +#[derive(Debug)] +pub struct SessionManager { + /// How sessions are managed. + pub(crate) mode: SessionMode, + /// Current session state. + pub(crate) state: SessionState, + /// The current session's UUID, if active. + pub(crate) session_id: Option, + /// Monotonically increasing session counter (persisted). + pub(crate) session_seq: u64, + /// Per-session event counter. + /// Uses AtomicU64 so `current_metadata_with_next_seq` can be called + /// with only `&SessionManager` (via `&Glean`) from `record_sync`. + pub(crate) event_seq: AtomicU64, + /// The sample rate as originally provided at initialization (0.0–1.0). + /// Never mutated after construction; used as the fallback when Remote + /// Settings has no active override for the session sample rate. + pub(crate) configured_sample_rate: f64, + /// The effective sample rate for the *current* session, reflecting any + /// Remote Settings override applied at session-start time. + /// Written once per session in `session_start()`; read by metadata helpers. + pub(crate) sample_rate: f64, + /// Whether the current session is sampled in. + pub(crate) sampled_in: bool, + /// Wall-clock timestamp at session start. `None` between sessions. + pub(crate) session_start_time: Option>, + /// When the session went inactive (for AUTO mode timeout evaluation). + pub(crate) inactive_since: Option>, + /// How long inactivity before a new session is started (AUTO mode). + /// `Duration::ZERO` means sessions never time out (always resumed). + pub(crate) inactivity_timeout: Duration, +} + +impl SessionManager { + /// Creates a new `SessionManager`. + /// + /// `sample_rate` is clamped to `[0.0, 1.0]`; values outside that range are + /// silently brought to the nearest bound. This matches the behaviour of the + /// remote-settings override path so the two paths are always consistent. + pub fn new(mode: SessionMode, sample_rate: f64, inactivity_timeout: Duration) -> Self { + let clamped = sample_rate.clamp(0.0, 1.0); + Self { + mode, + state: SessionState::Inactive, + session_id: None, + session_seq: 0, + event_seq: AtomicU64::new(0), + configured_sample_rate: clamped, + sample_rate: clamped, + sampled_in: true, // true between sessions so recording proceeds normally + session_start_time: None, + inactive_since: None, + inactivity_timeout, + } + } + + /// Returns whether the current session is sampled in. + /// + /// Returns `true` when no session is active (between sessions), + /// so telemetry recorded outside of a session is never suppressed. + pub fn is_sampled_in(&self) -> bool { + match self.state { + SessionState::Inactive => true, + SessionState::Active => self.sampled_in, + } + } + + /// Returns whether a session is currently active. + pub fn is_active(&self) -> bool { + self.state == SessionState::Active + } + + /// Returns the current session's UUID, if a session is active. + pub fn session_id(&self) -> Option { + self.session_id + } + + /// Returns whether the current session is sampled in (direct field access). + /// + /// Differs from `is_sampled_in` in that it returns the raw field value + /// without the "inactive → true" override, useful for asserting the exact + /// sampling decision made at session start. + pub fn sampled_in(&self) -> bool { + self.sampled_in + } + + /// Returns the wall-clock timestamp recorded when the current session started. + pub fn session_start_time(&self) -> Option> { + self.session_start_time + } + + /// Returns the current session's metadata without incrementing `event_seq`. + pub fn current_metadata(&self) -> Option { + if self.state != SessionState::Active { + return None; + } + let id = self.session_id?; + Some(SessionMetadata { + session_id: id.to_string(), + session_seq: self.session_seq, + event_seq: self.event_seq.load(Ordering::Relaxed), + session_sample_rate: self.sample_rate, + session_start_time: self + .session_start_time + .map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)), + }) + } + + /// Computes the session context (metadata attachment decision) for a single event. + /// + /// **Precondition:** the caller must have already verified via + /// `MetricType::should_record()` that the event should be recorded. That + /// check handles sampling suppression for all metric types; this function + /// is concerned only with *what context to attach*, not *whether to record*. + /// + /// Returns `OutOfSession` when no session is active (between sessions), or + /// `InSession(meta)` when an active session is present. + /// + /// `event_seq` is incremented only for `InSession` results so that + /// between-session events do not consume sequence numbers. + pub fn compute_event_context(&self) -> EventSessionContext { + match self.state { + SessionState::Inactive => EventSessionContext::OutOfSession, + SessionState::Active => { + // should_record() has already ensured sampled_in is true. + // current_metadata_with_next_seq increments event_seq atomically. + match self.current_metadata_with_next_seq() { + Some(meta) => EventSessionContext::InSession(meta), + // Defensive fallback: session_id was None despite Active state. + None => EventSessionContext::OutOfSession, + } + } + } + } + + /// Returns the current session's metadata with an atomically incremented `event_seq`. + /// + /// Called from `EventMetric::record_sync` which only holds `&Glean`. + pub fn current_metadata_with_next_seq(&self) -> Option { + if self.state != SessionState::Active { + return None; + } + let id = self.session_id?; + let seq = self.event_seq.fetch_add(1, Ordering::Relaxed); + Some(SessionMetadata { + session_id: id.to_string(), + session_seq: self.session_seq, + event_seq: seq, + session_sample_rate: self.sample_rate, + session_start_time: self + .session_start_time + .map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)), + }) + } +} + +// --------------------------------------------------------------------------- +// Sampling +// --------------------------------------------------------------------------- + +/// Converts a UUID to a deterministic sample value in [0, 1). +/// +/// Interprets the first 8 bytes as a big-endian u64 and divides by 2^64. +/// A session is sampled in when `uuid_to_sample_value(uuid) < sample_rate`. +/// +/// Dividing by 2^64 (not u64::MAX) guarantees the result is strictly less than +/// 1.0 for all inputs, so `sample_rate = 1.0` always samples every session. +pub(crate) fn uuid_to_sample_value(uuid: &Uuid) -> f64 { + let bytes = uuid.as_bytes(); + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes[..8]); + let n = u64::from_be_bytes(arr); + (n as f64) / 2.0f64.powi(64) +} + +// --------------------------------------------------------------------------- +// Persistence helpers +// --------------------------------------------------------------------------- + +fn make_session_seq_metric() -> QuantityMetric { + QuantityMetric::new(CommonMetricData { + name: SESSION_SEQ_METRIC_NAME.into(), + category: String::new(), + send_in_pings: vec![INTERNAL_STORAGE.into()], + lifetime: Lifetime::User, + ..Default::default() + }) +} + +fn make_session_id_metric() -> StringMetric { + StringMetric::new(CommonMetricData { + name: SESSION_ID_METRIC_NAME.into(), + category: String::new(), + send_in_pings: vec![INTERNAL_STORAGE.into()], + lifetime: Lifetime::User, + ..Default::default() + }) +} + +/// Stores the inactive-since timestamp as an RFC 3339 string. +/// An empty string (or absence of the key) means no recorded inactive_since. +fn make_inactive_since_metric() -> StringMetric { + StringMetric::new(CommonMetricData { + name: SESSION_INACTIVE_SINCE_METRIC_NAME.into(), + category: String::new(), + send_in_pings: vec![INTERNAL_STORAGE.into()], + lifetime: Lifetime::User, + ..Default::default() + }) +} + +/// Reads the current session sequence number from storage. +pub(crate) fn read_session_seq(glean: &Glean) -> u64 { + make_session_seq_metric() + .get_value(glean, INTERNAL_STORAGE) + .filter(|&v| v >= 0) + .map(|v| v as u64) + .unwrap_or(0) +} + +/// Persists the given session sequence number. +/// +/// `QuantityMetric` stores `i64`; the cast from `u64` is lossless for any +/// value below `i64::MAX` (~9.2 × 10^18). Values at or above that threshold +/// (unreachable in practice) would silently truncate, which is preferable to +/// a panic or corrupted sequence. +pub(crate) fn store_session_seq(glean: &Glean, seq: u64) { + make_session_seq_metric().set_sync(glean, seq as i64); +} + +/// Persists the current session ID. +/// Pass an empty string to indicate no active session. +pub(crate) fn persist_session_id(glean: &Glean, id: &str) { + make_session_id_metric().set_sync(glean, id); +} + +/// Clears the persisted session ID. +pub(crate) fn clear_session_id(glean: &Glean) { + make_session_id_metric().set_sync(glean, ""); +} + +/// Reads the persisted session ID, if any. +/// Returns `None` if no session ID is stored or if it was cleared. +pub(crate) fn read_session_id(glean: &Glean) -> Option { + let id = make_session_id_metric().get_value(glean, INTERNAL_STORAGE)?; + if id.is_empty() { + None + } else { + Some(id) + } +} + +/// Persists the inactive-since timestamp as an RFC 3339 string. +pub(crate) fn persist_inactive_since(glean: &Glean, ts: DateTime) { + make_inactive_since_metric().set_sync( + glean, + ts.to_rfc3339_opts(SecondsFormat::Millis, true).as_str(), + ); +} + +/// Reads the persisted inactive-since timestamp, if any. +/// Returns `None` if the key is absent or the stored string is empty. +pub(crate) fn read_inactive_since(glean: &Glean) -> Option> { + let s = make_inactive_since_metric().get_value(glean, INTERNAL_STORAGE)?; + if s.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(&s).ok() +} + +/// Clears the inactive-since timestamp by writing an empty string. +pub(crate) fn clear_inactive_since(glean: &Glean) { + make_inactive_since_metric().set_sync(glean, ""); +} + +// --------------------------------------------------------------------------- +// session_start_time persistence +// --------------------------------------------------------------------------- + +fn make_session_start_time_metric() -> StringMetric { + StringMetric::new(CommonMetricData { + name: SESSION_START_TIME_METRIC_NAME.into(), + category: String::new(), + send_in_pings: vec![INTERNAL_STORAGE.into()], + lifetime: Lifetime::User, + ..Default::default() + }) +} + +/// Persists the session start timestamp as an RFC 3339 string. +pub(crate) fn persist_session_start_time(glean: &Glean, ts: DateTime) { + make_session_start_time_metric().set_sync( + glean, + ts.to_rfc3339_opts(SecondsFormat::Millis, true).as_str(), + ); +} + +/// Reads the persisted session start timestamp, if any. +/// Returns `None` if the key is absent, empty, or unparseable. +pub(crate) fn read_session_start_time(glean: &Glean) -> Option> { + let s = make_session_start_time_metric().get_value(glean, INTERNAL_STORAGE)?; + if s.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(&s).ok() +} + +/// Clears the persisted session start timestamp. +pub(crate) fn clear_session_start_time(glean: &Glean) { + make_session_start_time_metric().set_sync(glean, ""); +} + +// --------------------------------------------------------------------------- +// session_event_seq persistence +// --------------------------------------------------------------------------- + +fn make_session_event_seq_metric() -> QuantityMetric { + QuantityMetric::new(CommonMetricData { + name: SESSION_EVENT_SEQ_METRIC_NAME.into(), + category: String::new(), + send_in_pings: vec![INTERNAL_STORAGE.into()], + lifetime: Lifetime::User, + ..Default::default() + }) +} + +/// Reads the persisted per-session event sequence counter. +/// +/// Returns `0` if no value has been stored (e.g. fresh session or after clear). +pub(crate) fn read_session_event_seq(glean: &Glean) -> u64 { + make_session_event_seq_metric() + .get_value(glean, INTERNAL_STORAGE) + .filter(|&v| v >= 0) + .map(|v| v as u64) + .unwrap_or(0) +} + +/// Persists the per-session event sequence counter. +/// +/// Should be called whenever the in-memory `event_seq` changes and persistence +/// is required (i.e. on `session_transition_to_inactive`). The cast from +/// `u64` is lossless for any value below `i64::MAX`. +pub(crate) fn store_session_event_seq(glean: &Glean, seq: u64) { + make_session_event_seq_metric().set_sync(glean, seq as i64); +} + +/// Clears the persisted event sequence counter (stores 0). +/// +/// Called when a session ends so a resumed session from a stale storage entry +/// does not inherit a stale counter. +pub(crate) fn clear_session_event_seq(glean: &Glean) { + make_session_event_seq_metric().set_sync(glean, 0); +} diff --git a/glean-core/tests/clientid_textfile.rs b/glean-core/tests/clientid_textfile.rs index 41b9a34d5d..0f1ebf71ab 100644 --- a/glean-core/tests/clientid_textfile.rs +++ b/glean-core/tests/clientid_textfile.rs @@ -24,6 +24,7 @@ fn clientid_metric() -> UuidMetric { lifetime: Lifetime::User, disabled: false, dynamic_label: None, + in_session: false, }) } diff --git a/glean-core/tests/common/mod.rs b/glean-core/tests/common/mod.rs index 17b8f64537..58c9a7530f 100644 --- a/glean-core/tests/common/mod.rs +++ b/glean-core/tests/common/mod.rs @@ -69,6 +69,9 @@ pub fn new_glean_with_upload( ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let mut glean = Glean::new(cfg).unwrap(); diff --git a/glean-core/tests/event.rs b/glean-core/tests/event.rs index ff6dde99be..893b55d860 100644 --- a/glean-core/tests/event.rs +++ b/glean-core/tests/event.rs @@ -553,6 +553,9 @@ fn with_event_timestamps() { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }; let mut glean = Glean::new(cfg).unwrap(); let ping = PingBuilder::new("store1").build(); diff --git a/glean-core/tests/metrics_sync.rs b/glean-core/tests/metrics_sync.rs index bd4ab49da4..98c6c3b869 100644 --- a/glean-core/tests/metrics_sync.rs +++ b/glean-core/tests/metrics_sync.rs @@ -23,8 +23,10 @@ static DEFINITION_ONLY: &[&str] = &[ "glean.internal.metrics.telemetry_sdk_build", /* adhoc in src/ping/mod.rs */ "glean.ping.uploader_capabilities", - /* adhoc event */ + /* adhoc events */ "glean.restarted", + "glean.session_end", + "glean.session_start", /* in foreign language wrapper */ "glean.validation.foreground_count", ]; diff --git a/glean-core/tests/ping.rs b/glean-core/tests/ping.rs index cb63092160..dc2622bfc5 100644 --- a/glean-core/tests/ping.rs +++ b/glean-core/tests/ping.rs @@ -137,6 +137,7 @@ fn test_pings_submitted_metric() { lifetime: Lifetime::Ping, disabled: false, dynamic_label: None, + ..Default::default() }, }, None, diff --git a/glean-core/tests/ping_maker.rs b/glean-core/tests/ping_maker.rs index d0bd3b4805..8fe6e8b7f3 100644 --- a/glean-core/tests/ping_maker.rs +++ b/glean-core/tests/ping_maker.rs @@ -96,6 +96,9 @@ fn test_metrics_must_report_experimentation_id() { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }) .unwrap(); let ping_maker = PingMaker::new(); @@ -151,6 +154,9 @@ fn experimentation_id_is_removed_if_send_if_empty_is_false() { ping_schedule: Default::default(), ping_lifetime_threshold: 0, ping_lifetime_max_time: 0, + session_mode: glean_core::SessionMode::Auto, + session_sample_rate: 1.0, + session_inactivity_timeout_ms: 1_800_000, }) .unwrap(); let ping_maker = PingMaker::new(); diff --git a/glean-core/tests/session.rs b/glean-core/tests/session.rs new file mode 100644 index 0000000000..84879db821 --- /dev/null +++ b/glean-core/tests/session.rs @@ -0,0 +1,1295 @@ +// 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, + 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!["health".into()], + lifetime: Lifetime::Ping, + in_session: false, + ..Default::default() + }); + + // No sessions started yet. + assert!( + sessions_seen.get_value(&glean, "health").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, "health").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!["health".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, "health").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); +} diff --git a/glean.1.schema.json b/glean.1.schema.json index 48585718e0..3c8acbb5cb 100644 --- a/glean.1.schema.json +++ b/glean.1.schema.json @@ -111,6 +111,43 @@ "timestamp": { "minimum": 0, "type": "integer" + }, + "session": { + "type": "object", + "description": "Session metadata attached to this event. Absent for out-of-session events and events from before sessions were introduced.", + "additionalProperties": false, + "required": [ + "session_id", + "session_seq", + "event_seq", + "session_sample_rate" + ], + "properties": { + "session_id": { + "type": "string", + "description": "The unique UUID for this session." + }, + "session_seq": { + "type": "integer", + "minimum": 0, + "description": "Monotonically increasing session counter, persisted across restarts." + }, + "event_seq": { + "type": "integer", + "minimum": 0, + "description": "Per-session event counter, reset at each new session." + }, + "session_sample_rate": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "The sampling rate in effect for this session." + }, + "session_start_time": { + "type": "string", + "description": "Wall-clock timestamp at session start (RFC 3339). Absent on events from before this field was introduced." + } + } } }, "required": [ diff --git a/samples/ios/app/glean-sample-appUITests/EventPingTest.swift b/samples/ios/app/glean-sample-appUITests/EventPingTest.swift index 86a2d4fd36..0f55ac3d14 100644 --- a/samples/ios/app/glean-sample-appUITests/EventPingTest.swift +++ b/samples/ios/app/glean-sample-appUITests/EventPingTest.swift @@ -27,12 +27,26 @@ class EventPingTest: XCTestCase { func setupServer(expectPingType: String) -> HttpServer { return mockServer(expectPingType: expectPingType) { json in + let pingInfo = json!["ping_info"] as! [String: Any] + let reason = pingInfo["reason"] as! String + // Skip the "startup" events ping (which carries leftover events and + // the session_start boundary event from a previous run). We're waiting + // for the "inactive" events ping triggered by backgrounding. + if reason != "inactive" { + return + } self.lastPingJson = json // Fulfill test's expectation once we parsed the incoming data. self.expectation?.fulfill() } } + /// Filters out internal Glean session boundary events (category == "glean") + /// so tests can check only user-recorded events. + private func userEvents(from events: [[String: Any]]) -> [[String: Any]] { + return events.filter { ($0["category"] as? String) != "glean" } + } + // We launch the app, tap the record button a couple of times, // then restart the app, which should trigger an event ping. func testValidateEventPing() { @@ -69,14 +83,14 @@ class EventPingTest: XCTestCase { let reason = pingInfo["reason"] as! String XCTAssertEqual("inactive", reason, "Should have gotten a inactive events ping") - let events = lastPingJson!["events"] as! [[String: Any]] + let allEvents = lastPingJson!["events"] as! [[String: Any]] + // Session boundary events (glean.session_start/session_end) share the + // events ping; filter to the events recorded by the sample app. + let events = userEvents(from: allEvents) // 4 taps total, per button tap we record 2 events. let expectedCount = 4 * 2 XCTAssertEqual(expectedCount, events.count, "Events ping should have all button-tap events") - let firstEvent = events[0] - XCTAssertEqual(0, firstEvent["timestamp"] as! Int, "First event should be at timestamp 0") - for i in 1...(expectedCount-1) { let earlier = events[i-1]["timestamp"] as! Int let this = events[i]["timestamp"] as! Int