1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs";
9 ChromeUtils.defineESModuleGetters(lazy, {
10 ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
11 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
12 ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
13 Heartbeat: "resource://normandy/lib/Heartbeat.sys.mjs",
14 NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
15 ShellService: "resource:///modules/ShellService.sys.mjs",
16 Storage: "resource://normandy/lib/Storage.sys.mjs",
17 UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
20 ChromeUtils.defineLazyGetter(lazy, "gAllRecipeStorage", function () {
21 return new lazy.Storage("normandy-heartbeat");
24 const DAY_IN_MS = 24 * 60 * 60 * 1000;
25 const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS;
27 export class ShowHeartbeatAction extends BaseAction {
28 static Heartbeat = lazy.Heartbeat;
30 static overrideHeartbeatForTests(newHeartbeat) {
32 this.Heartbeat = newHeartbeat;
34 this.Heartbeat = lazy.Heartbeat;
39 return lazy.ActionSchemas["show-heartbeat"];
45 engagementButtonLabel,
51 const recipeStorage = new lazy.Storage(recipe.id);
53 if (!(await this.shouldShow(recipeStorage, recipe))) {
58 `Heartbeat for recipe ${recipe.id} showing prompt "${message}"`
60 const targetWindow = lazy.BrowserWindowTracker.getTopWindow();
63 throw new Error("No window to show heartbeat in");
66 const heartbeat = new ShowHeartbeatAction.Heartbeat(targetWindow, {
67 surveyId: this.generateSurveyId(recipe),
69 engagementButtonLabel,
73 postAnswerUrl: await this.generatePostAnswerURL(recipe),
74 flowId: lazy.NormandyUtils.generateUuid(),
75 surveyVersion: recipe.revision_id,
78 heartbeat.eventEmitter.once(
80 this.updateLastInteraction.bind(this, recipeStorage)
82 heartbeat.eventEmitter.once(
84 this.updateLastInteraction.bind(this, recipeStorage)
89 lazy.gAllRecipeStorage.setItem("lastShown", now),
90 recipeStorage.setItem("lastShown", now),
94 async shouldShow(recipeStorage, recipe) {
95 const { repeatOption, repeatEvery } = recipe.arguments;
96 // Don't show any heartbeats to a user more than once per throttle period
97 let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
99 const duration = new Date() - lastShown;
100 if (duration < HEARTBEAT_THROTTLE) {
101 // show the number of hours since the last heartbeat, with at most 1 decimal point.
102 const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
104 `A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.`
110 switch (repeatOption) {
112 // Don't show if we've ever shown before
113 if (await recipeStorage.getItem("lastShown")) {
115 `Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.`
123 // Show a heartbeat again only if the user has not interacted with it before
124 if (await recipeStorage.getItem("lastInteraction")) {
126 `Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.`
134 // Show this heartbeat again if it has been at least `repeatEvery` days since the last time it was shown.
135 let lastShown = await lazy.gAllRecipeStorage.getItem("lastShown");
137 lastShown = new Date(lastShown);
138 const duration = new Date() - lastShown;
139 if (duration < repeatEvery * DAY_IN_MS) {
140 // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point.
141 const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
143 `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)`
155 * Returns a surveyId value. If recipe calls to include the Normandy client
156 * ID, then the client ID is attached to the surveyId in the format
157 * `${surveyId}::${userId}`.
159 * @return {String} Survey ID, possibly with user UUID
161 generateSurveyId(recipe) {
162 const { includeTelemetryUUID, surveyId } = recipe.arguments;
163 if (includeTelemetryUUID) {
164 return `${surveyId}::${lazy.ClientEnvironment.userId}`;
170 * Generate the appropriate post-answer URL for a recipe.
172 * @return {String} URL with post-answer query params
174 async generatePostAnswerURL(recipe) {
175 const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
177 // Don`t bother with empty URLs.
178 if (!postAnswerUrl) {
179 return postAnswerUrl;
182 const userId = lazy.ClientEnvironment.userId;
183 const searchEngine = (await Services.search.getDefault()).identifier;
185 fxVersion: Services.appinfo.version,
186 isDefaultBrowser: lazy.ShellService.isDefaultBrowser() ? 1 : 0,
189 // `surveyversion` used to be the version of the heartbeat action when it
190 // was hosted on a server. Keeping it around for compatibility.
191 surveyversion: Services.appinfo.version,
192 syncSetup: Services.prefs.prefHasUserValue("services.sync.username")
195 updateChannel: lazy.UpdateUtils.getUpdateChannel(false),
196 utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")),
197 utm_medium: recipe.action,
198 utm_source: "firefox",
200 if (includeTelemetryUUID) {
201 args.userId = userId;
204 let url = new URL(postAnswerUrl);
205 // create a URL object to append arguments to
206 for (const [key, val] of Object.entries(args)) {
207 if (!url.searchParams.has(key)) {
208 url.searchParams.set(key, val);
212 // return the address with encoded queries
213 return url.toString();
216 updateLastInteraction(recipeStorage) {
217 recipeStorage.setItem("lastInteraction", Date.now());