diff --git a/CHANGELOG.md b/CHANGELOG.md index d4a0aac5e..cc3fc9746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ * [#1848](https://github.com/mozilla/glean.js/pull/1848): Support for automatically collecting element click events (first version) * [#1849](https://github.com/mozilla/glean.js/pull/1849): Truncate event extra strings to 500 bytes. This also updates other string-based metrics to truncate based on max bytes rather than a set number of characters. +* [#1850](https://github.com/mozilla/glean.js/pull/1850): Automatically record basic session information (`session_id` & `session_count`) for web properties. + # v4.0.0-pre.2 (2023-12-06) [Full changelog](https://github.com/mozilla/glean.js/compare/v4.0.0-pre.1...v4.0.0-pre.2) diff --git a/automation/compat/index.html b/automation/compat/index.html index 3008fcc53..7d0ba3125 100644 --- a/automation/compat/index.html +++ b/automation/compat/index.html @@ -7,7 +7,8 @@ Glean Benchmarks Sample -

+

+

diff --git a/automation/compat/index.js b/automation/compat/index.js index f7e4f2894..0ff4bb4d6 100644 --- a/automation/compat/index.js +++ b/automation/compat/index.js @@ -8,19 +8,12 @@ import Glean from "@mozilla/glean/web"; import { benchmark } from "./generated/pings.js"; import * as metrics from "./generated/sample.js"; -Glean.setSourceTags(["automation"]); -Glean.initialize("glean-compat-benchmark", true, { - enableAutoPageLoadEvents: true -}); - -metrics.pageLoaded.set(); -benchmark.submit(); - // !BIG HACK! // // Overwrite the console.info function in order to know when (and if) the benchmark ping was sent. // If a success ping message is logged we show that in the document. let pingSubmissionCount = 0; +let sessionId = ""; console.info = function () { var message = ""; for (var i = 0; i < arguments.length; i++) { @@ -30,6 +23,7 @@ console.info = function () { message += " "; } } + console.log(message); if (/successfully sent 200.$/.test(message)) { pingSubmissionCount++; @@ -38,8 +32,40 @@ console.info = function () { // 1. The built-in page_load event, which submits an events ping. // 2. The benchmark ping. if (pingSubmissionCount == 2) { - var elem = document.getElementById("msg"); - elem.innerHTML = "Pings submitted successfully."; + var elem = document.getElementById("ping_msg"); + elem.innerText = "Pings submitted successfully."; } } + + const sessionRegex = /"session_id": .+"/; + const sessionInfo = sessionRegex.exec(message); + if (!!sessionInfo) { + const currSessionId = sessionInfo?.[0].split(`"`)?.[3]; + if (!!sessionId) { + if (currSessionId !== sessionId) { + var elem = document.getElementById("session_msg"); + elem.innerText = "Session IDs updated successfully."; + } else { + console.log("Something went wrong..."); + } + } + + sessionId = currSessionId; + } } + +Glean.setSourceTags(["automation"]); + +// This needs to be set so we can pull the session ID from the log messages. +Glean.setLogPings(true); + +Glean.initialize("glean-compat-benchmark", true, { + enableAutoPageLoadEvents: true, + // Setting the override to 0 means every action will trigger + // a new session. We use this to check that the session ID + // changes whenever a session has expired. + sessionLengthInMinutesOverride: 0 +}); + +metrics.pageLoaded.set(); +benchmark.submit(); diff --git a/automation/compat/tests/utils.js b/automation/compat/tests/utils.js index 2af9146da..929d31994 100644 --- a/automation/compat/tests/utils.js +++ b/automation/compat/tests/utils.js @@ -73,7 +73,7 @@ export async function runWebTest(driver) { // will receive the text "Ping submitted successfully." await driver.get(`http://localhost:${PORT}/`); // Give it time to send the ping request. - const successTextContainer = await driver.findElement(By.id("msg")); + const pingTextContainer = await driver.findElement(By.id("ping_msg")); const areGleanWindowVarsSet = await driver.executeScript(() => { // Verify that all Glean `window` vars are properly set. @@ -100,10 +100,17 @@ export async function runWebTest(driver) { await driver.wait( until.elementTextIs( - successTextContainer, + pingTextContainer, "Pings submitted successfully." ), 11_000); // 1s more than the default upload timeout in Glean. + const sessionTextContainer = await driver.findElement(By.id("session_msg")); + await driver.wait( + until.elementTextIs( + sessionTextContainer, + "Session IDs updated successfully." + ), 1000); + console.log("Test passed."); } catch(e) { console.log("Test failed.", e); diff --git a/glean/src/core/config.ts b/glean/src/core/config.ts index daafe308e..98e41587d 100644 --- a/glean/src/core/config.ts +++ b/glean/src/core/config.ts @@ -55,6 +55,8 @@ export interface ConfigurationInterface { readonly enableAutoElementClickEvents?: boolean, // Experimentation identifier to be set in all pings experimentationId?: string, + // Allows custom session length, in minutes. The default value is 30 minutes. + readonly sessionLengthInMinutesOverride?: number, } // Important: the `Configuration` should only be used internally by the Glean singleton. @@ -69,6 +71,7 @@ export class Configuration implements ConfigurationInterface { readonly enableAutoPageLoadEvents?: boolean; readonly enableAutoElementClickEvents?: boolean; experimentationId?: string; + readonly sessionLengthInMinutesOverride?: number; // Debug configuration. debug: DebugOptions; @@ -85,6 +88,7 @@ export class Configuration implements ConfigurationInterface { this.enableAutoPageLoadEvents = config?.enableAutoPageLoadEvents; this.enableAutoElementClickEvents = config?.enableAutoElementClickEvents; this.experimentationId = config?.experimentationId; + this.sessionLengthInMinutesOverride = config?.sessionLengthInMinutesOverride; this.debug = {}; diff --git a/glean/src/core/internal_metrics.ts b/glean/src/core/internal_metrics.ts index 08dc4f341..62791777d 100644 --- a/glean/src/core/internal_metrics.ts +++ b/glean/src/core/internal_metrics.ts @@ -6,12 +6,14 @@ import { KNOWN_CLIENT_ID, CLIENT_INFO_STORAGE } from "./constants.js"; import { InternalUUIDMetricType as UUIDMetricType } from "./metrics/types/uuid.js"; import { InternalDatetimeMetricType as DatetimeMetricType } from "./metrics/types/datetime.js"; import { InternalStringMetricType as StringMetricType } from "./metrics/types/string.js"; +import { InternalCounterMetricType as CounterMetricType } from "./metrics/types/counter.js"; import { createMetric } from "./metrics/utils.js"; import TimeUnit from "./metrics/time_unit.js"; import { generateUUIDv4, isWindowObjectUnavailable } from "./utils.js"; import { Lifetime } from "./metrics/lifetime.js"; import log, { LoggingLevel } from "./log.js"; import { Context } from "./context.js"; +import { isSessionInactive } from "./sessions.js"; const LOG_TAG = "core.InternalMetrics"; @@ -28,6 +30,8 @@ export class CoreMetrics { readonly osVersion: StringMetricType; readonly architecture: StringMetricType; readonly locale: StringMetricType; + readonly sessionId: UUIDMetricType; + readonly sessionCount: CounterMetricType; // Provided by the user readonly appChannel: StringMetricType; readonly appBuild: StringMetricType; @@ -120,6 +124,22 @@ export class CoreMetrics { }, "second" ); + + this.sessionId = new UUIDMetricType({ + name: "session_id", + category: "", + sendInPings: ["glean_client_info"], + lifetime: Lifetime.User, + disabled: false + }); + + this.sessionCount = new CounterMetricType({ + name: "session_count", + category: "", + sendInPings: ["glean_client_info"], + lifetime: Lifetime.User, + disabled: false + }); } initialize(migrateFromLegacyStorage?: boolean): void { @@ -145,6 +165,8 @@ export class CoreMetrics { this.initializeUserLifetimeMetrics(); } + this.updateSessionInfo(); + this.os.set(Context.platform.info.os()); this.osVersion.set(Context.platform.info.osVersion()); this.architecture.set(Context.platform.info.arch()); @@ -159,6 +181,53 @@ export class CoreMetrics { } } + /** + * Update local stored session information for Glean. This is called whenever + * the app on initialization and just after every read/write/delete to/from + * storage. + * + * There are a few scenarios to handle depending on what we already have + * stored about the session and how long it has been since the last action. + * + * SCENARIOS: + * + * 1. If this is the first session (there is no existing session ID), + * then we set a new session ID and a lastActive timestamp. + * + * 2. If the session is not expired, then we only update the lastActive time. + * + * 3. If the session is expired (inactive threshold is more recent than lastActive) + * then we update the session ID, the session sequence number, and the lastActive time. + */ + updateSessionInfo(): void { + if (isWindowObjectUnavailable()) { + return; + } + + const existingSessionId = Context.metricsDatabase.getMetric( + CLIENT_INFO_STORAGE, + this.sessionId + ); + + if (existingSessionId) { + try { + // If the session has timed out, then we create a new session. + if (isSessionInactive(Context.config.sessionLengthInMinutesOverride)) { + this.generateNewSession(); + } + } catch (e) { + // Error parsing the last active timestamp, create a new session. + this.generateNewSession(); + } + } else { + // There is no previous session information, create a new session. + this.generateNewSession(); + } + + // Update the last-active timestamp in LocalStorage to the current time. + localStorage.setItem("glean_session_last_active", Date.now().toString()); + } + /** * Generates and sets the client_id if it is not set, * or if the current value is corrupted. @@ -202,6 +271,11 @@ export class CoreMetrics { } } + private generateNewSession(): void { + this.sessionId.generateAndSet(); + this.sessionCount.add(); + } + /** * Initializes the Glean internal user-lifetime metrics. */ diff --git a/glean/src/core/pings/maker.ts b/glean/src/core/pings/maker.ts index 506c5ac47..ae22d194b 100644 --- a/glean/src/core/pings/maker.ts +++ b/glean/src/core/pings/maker.ts @@ -216,6 +216,9 @@ export function buildClientInfoSection(ping: CommonPingData): ClientInfo { if (!ping.includeClientId) { delete finalClientInfo["client_id"]; + + // If the ping doesn't include the client_id, we also should exclude session_id. + delete finalClientInfo["session_id"]; } return finalClientInfo; diff --git a/glean/src/core/pings/ping_payload.ts b/glean/src/core/pings/ping_payload.ts index 54a8f3637..20bd2f392 100644 --- a/glean/src/core/pings/ping_payload.ts +++ b/glean/src/core/pings/ping_payload.ts @@ -27,7 +27,9 @@ export interface ClientInfo extends JSONObject { first_run_date?: string, os?: string, os_version?: string, - telemetry_sdk_build: string + telemetry_sdk_build: string, + session_id?: string, + session_count?: number } /** diff --git a/glean/src/core/sessions.ts b/glean/src/core/sessions.ts new file mode 100644 index 000000000..f0d127b1c --- /dev/null +++ b/glean/src/core/sessions.ts @@ -0,0 +1,22 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +/** + * Check if the current session is inactive - the default timeout is 30 minutes. + * + * @param sessionLengthInMinutes Length of the session in minutes - defaults to 30. + * @returns {boolean} If the current session is inactive. + */ +export function isSessionInactive(sessionLengthInMinutes = 30): boolean { + const lastActive = localStorage.getItem("glean_session_last_active"); + const lastActiveDate = new Date(Number(lastActive)); + + // Subtract the session length from the current date. + const inactiveThreshold = new Date(); + inactiveThreshold.setMinutes(inactiveThreshold.getMinutes() - sessionLengthInMinutes); + + // If the inactiveThreshold is more recent than the lastActiveDate, then the + // current session is expired. + return inactiveThreshold > lastActiveDate; +} diff --git a/glean/src/metrics.yaml b/glean/src/metrics.yaml index fbe3a703d..f8df19e60 100644 --- a/glean/src/metrics.yaml +++ b/glean/src/metrics.yaml @@ -122,6 +122,51 @@ glean.internal.metrics: - glean-team@mozilla.com expires: never + session_id: + type: uuid + description: | + A UUID uniquely identifying the client's current session. A session is + the period of time in which a user interacts with the application. After + a period of inactivity (default being 30 minutes) a new session will be + created the next time the user interacts with the application. On each + new session, the session_id will be updated. + + This metric WILL NOT be included for pings where `include_client_id` is `false`. + send_in_pings: + - glean_client_info + lifetime: user + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862955 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862955#c2 + data_sensitivity: + - technical + notification_emails: + - glean-team@mozilla.com + expires: never + + session_count: + type: counter + description: | + A running counter of the number of sessions for this client. A session is + the period of time in which a user interacts with the application. After + a period of inactivity (default being 30 minutes) a new session will be + created the next time the user interacts with the application. On each + new session, the session_count will be incremented. + This count will ONLY be reset on opt-out or whenever storage is deleted. + send_in_pings: + - glean_client_info + lifetime: user + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862955 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1862955#c2 + data_sensitivity: + - technical + notification_emails: + - glean-team@mozilla.com + expires: never + app_build: type: string lifetime: application diff --git a/glean/src/platform/browser/web/storage.ts b/glean/src/platform/browser/web/storage.ts index 2fcf4d592..9dabbd911 100644 --- a/glean/src/platform/browser/web/storage.ts +++ b/glean/src/platform/browser/web/storage.ts @@ -6,6 +6,7 @@ import type Store from "../../../core/storage.js"; import type { StorageIndex } from "../../../core/storage.js"; import type { JSONObject, JSONValue } from "../../../core/utils.js"; +import { Context } from "../../../core/context.js"; import log, { LoggingLevel } from "../../../core/log.js"; import { deleteKeyFromNestedObject, @@ -44,6 +45,10 @@ class WebStore implements Store { log(LOG_TAG, ["Unable to fetch value from local storage.", err], LoggingLevel.Error); } + if (this.shouldUpdateSession(index)) { + Context.coreMetrics.updateSessionInfo(); + } + return result; } @@ -61,6 +66,10 @@ class WebStore implements Store { } catch (err) { log(LOG_TAG, ["Unable to update value from local storage.", err], LoggingLevel.Error); } + + if (this.shouldUpdateSession(index)) { + Context.coreMetrics.updateSessionInfo(); + } } delete(index: StorageIndex): void { @@ -89,6 +98,23 @@ class WebStore implements Store { } catch (err) { log(LOG_TAG, ["Unable to delete value from storage.", err], LoggingLevel.Error); } + + if (this.shouldUpdateSession(index)) { + Context.coreMetrics.updateSessionInfo(); + } + } + + /** + * Check to see if the session information should be updated whenever + * interacting with storage. If we are updating the existing session metrics + * then running the `updateSessionInfo` function again would result in an + * infinite loop of updating the metrics and re-running this function. + * + * @param {StorageIndex} index Index to update in storage. + * @returns {boolean} Whether we should update session metrics. + */ + private shouldUpdateSession(index: StorageIndex): boolean { + return !index.includes("session_id") && !index.includes("session_count"); } }