[FF:DriveWebDownloadSWModernBrowsers] Modern Browsers Downloads
[ProtonMail-WebClient.git] / applications / drive / src / app / utils / telemetry.ts
blobd25b9659cf29404394c28aa66b78a6c08714c4e6
1 import { useEffect, useMemo } from 'react';
3 import { useApi } from '@proton/components';
4 import { CryptoProxy } from '@proton/crypto';
5 import createApi from '@proton/shared/lib/api/createApi';
6 import localStorageWithExpiry from '@proton/shared/lib/api/helpers/localStorageWithExpiry';
7 import { TelemetryDriveWebFeature, TelemetryMeasurementGroups } from '@proton/shared/lib/api/telemetry';
8 import { sendTelemetryReport } from '@proton/shared/lib/helpers/metrics';
9 import { randomHexString4 } from '@proton/shared/lib/helpers/uid';
10 import type { Api } from '@proton/shared/lib/interfaces';
11 import noop from '@proton/utils/noop';
13 import * as config from '../config';
14 import { LAST_ACTIVE_PING } from '../store/_user/useActivePing';
15 import { sendErrorReport } from './errorHandling';
16 import { EnrichedError } from './errorHandling/EnrichedError';
17 import { getLastActivePersistedUserSession } from './lastActivePersistedUserSession';
19 export enum ExperimentGroup {
20     control = 'control',
21     treatment = 'treatment',
24 export enum Features {
25     mountToFirstItemRendered = 'mountToFirstItemRendered',
26     totalSizeComputation = 'totalSizeComputation',
27     sharingLoadLinksByVolume = 'sharingLoadLinksByVolume',
30 /**
31  * At the present time, these metrics will only work when user is authenticated
32  */
33 export enum Actions {
34     AddToBookmark = 'addToBookmark',
35     ViewSignUpFlowModal = 'viewSignUpFlowModal',
36     DismissSignUpFlowModal = 'dismissSignUpFlowModal',
37     SubmitSignUpFlowModal = 'submitSignUpFlowModal',
38     SignUpFlowModal = 'signUpFlowModal',
39     SignInFlowModal = 'signInFlowModal',
40     OpenPublicLinkFromSharedWithMe = 'openPublicLinkFromSharedWithMe',
41     PublicScanAndDownload = 'publicScanAndDownload',
42     PublicDownload = 'publicDownload',
43     PublicLinkVisit = 'publicLinkVisit',
44     DeleteBookmarkFromSharedWithMe = 'DeleteBookmarkFromSharedWithMe',
45     SignUpFlowAndRedirectCompleted = 'signUpFlowAndRedirectCompleted',
46     RedirectToCorrectContextShare = 'redirectToCorrectContextShare',
47     RedirectToCorrectAcceptInvitation = 'RedirectToCorrectAcceptInvitation',
48     AddToBookmarkTriggeredModal = 'addToBookmarkTriggeredModal',
49     // onboarding actions
50     OnboardingV2Shown = 'onboardingV2Shown',
51     OnboardingV2InstallMacApp = 'onboardingV2InstallMacApp',
52     OnboardingV2InstallWindowsApp = 'onboardingV2InstallWindowsApp',
53     OnboardingV2InstallSkip = 'onboardingV2InstallSkip',
54     OnboardingV2B2BInvite = 'onboardingV2B2BInvite',
55     OnboardingV2B2BInviteSkip = 'onboardingV2B2BInviteSkip',
56     OnboardingV2UploadFile = 'onboardingV2UploadFile',
57     OnboardingV2UploadFolder = 'onboardingV2UploadFolder',
58     OnboardingV2UploadSkip = 'onboardingV2UploadSkip',
60     // Download info on what system will be used for file with size above MEMORY_DOWNLOAD_LIMIT
61     DownloadUsingSW = 'downloadUsingSW',
62     DownloadFallback = 'downloadFallback',
65 type PerformanceTelemetryAdditionalValues = {
66     quantity?: number;
69 const sendTelemetryFeaturePerformance = (
70     api: Api,
71     featureName: Features,
72     timeInMs: number,
73     treatment: ExperimentGroup,
74     additionalValues: PerformanceTelemetryAdditionalValues = {}
75 ) => {
76     // TODO: https://jira.protontech.ch/browse/DD-7
77     // This is ugly and hacky, but it's a cheap way for the metric (without calling /users and without hooks)
78     // Proper back-end solution will be done as part of https://jira.protontech.ch/browse/DD-7
79     const loggedInRecently = localStorageWithExpiry.getData(LAST_ACTIVE_PING) ? 'true' : 'false';
81     void sendTelemetryReport({
82         api: api,
83         measurementGroup: TelemetryMeasurementGroups.driveWebFeaturePerformance,
84         event: TelemetryDriveWebFeature.performance,
85         values: {
86             milliseconds: timeInMs,
87             ...additionalValues,
88         },
89         dimensions: {
90             isLoggedIn: window.location.pathname.startsWith('/urls') ? loggedInRecently : 'true',
91             experimentGroup: treatment,
92             featureName,
93         },
94     });
97 const measureAndReport = (
98     api: Api,
99     feature: Features,
100     group: ExperimentGroup,
101     measureName: string,
102     startMark: string,
103     endMark: string,
104     additionalValues?: PerformanceTelemetryAdditionalValues
105 ) => {
106     try {
107         const measure = performance.measure(measureName, startMark, endMark);
108         // it can be undefined on browsers below Safari below 14.1 and Firefox 103
109         if (measure) {
110             sendTelemetryFeaturePerformance(api, feature, measure.duration, group, additionalValues);
111         }
112     } catch (e) {
113         sendErrorReport(
114             new EnrichedError('Telemetry Performance Error', {
115                 extra: {
116                     e,
117                 },
118             })
119         );
120     }
124  * Executes a feature group with either control or treatment functions.
126  * @param {Features} feature The type of feature to execute (e.g. 'A', 'B', etc.) defined in the `Features` enum
127  * @param {boolean} applyTreatment Whether to execute the treatment or control function
128  * @param {(()) => Promise<T>} controlFunction A function returning a Promise that should be fulfilled when `applyTreatment` is false
129  * @param {(()) => Promise<T>} treatmentFunction A function returning a Promise that should be fulfilled when `applyTreatment` is true
130  * @returns {Promise<T>} The Promise of the executed function, returns T when fulfilled, both control and treatment should have same return type
131  */
132 export const measureExperimentalPerformance = <T>(
133     api: Api,
134     feature: Features,
135     applyTreatment: boolean,
136     controlFunction: () => Promise<T>,
137     treatmentFunction: () => Promise<T>
138 ): Promise<T> => {
139     // Something somewhat unique, if we have collision it's not end of the world we drop the metric
140     const distinguisher = `${performance.now()}-${randomHexString4()}`;
141     const startMark = `start-${feature}-${distinguisher}`;
142     const endMark = `end-${feature}-${distinguisher}`;
143     const measureName = `measure-${feature}-${distinguisher}`;
145     performance.mark(startMark);
146     const result = applyTreatment ? treatmentFunction() : controlFunction();
148     result
149         .finally(() => {
150             performance.mark(endMark);
152             measureAndReport(
153                 api,
154                 feature,
155                 applyTreatment ? ExperimentGroup.treatment : ExperimentGroup.control,
156                 measureName,
157                 startMark,
158                 endMark
159             );
161             performance.clearMarks(endMark);
162             performance.clearMeasures(measureName);
163             performance.clearMarks(startMark);
164         })
165         .catch(noop);
167     return result;
170 export const measureFeaturePerformance = (api: Api, feature: Features, experimentGroup = ExperimentGroup.control) => {
171     const startMark = `start-${feature}-${randomHexString4()}`;
172     const endMark = `end-${feature}-${randomHexString4()}`;
173     const measureName = `measure-${feature}-${randomHexString4()}`;
175     let started = false;
176     let ended = false;
178     const clear = () => {
179         performance.clearMarks(startMark);
180         performance.clearMarks(endMark);
181         performance.clearMeasures(measureName);
182         started = false;
183         ended = false;
184     };
186     return {
187         start: () => {
188             if (!started) {
189                 started = true;
190                 performance.mark(startMark);
191             }
192         },
193         end: (additionalValues?: PerformanceTelemetryAdditionalValues) => {
194             if (!ended && started) {
195                 ended = true;
196                 performance.mark(endMark);
197                 measureAndReport(api, feature, experimentGroup, measureName, startMark, endMark, additionalValues);
198                 clear();
199             }
200         },
201         clear,
202     };
205 export const useMeasureFeaturePerformanceOnMount = (features: Features) => {
206     const api = useApi();
208     // It will be a new measure object each time the api changes or the features changes.
209     // If it changes in between the previous measure has started and not ended yet the values will be cleared and ignored.
210     const measure = useMemo(() => measureFeaturePerformance(api, features), [api, features]);
212     useEffect(() => {
213         measure.start();
214         return () => {
215             measure.clear();
216         };
217     }, [measure]);
219     return measure.end;
222 const apiInstance = createApi({ config, sendLocaleHeaders: true });
224 export const countActionWithTelemetry = (action: Actions, count: number = 1) => {
225     const persistedSession = getLastActivePersistedUserSession();
227     if (persistedSession?.UID) {
228         // API calls will now be Authenticated with x-pm-uid header
229         apiInstance.UID = persistedSession?.UID;
230     }
232     // TODO: https://jira.protontech.ch/browse/DD-7
233     // This is ugly and hacky, but it's a cheap way for the metric (without calling /users and without hooks)
234     // Proper back-end solution will be done as part of https://jira.protontech.ch/browse/DD-7
235     const loggedInRecently = localStorageWithExpiry.getData(LAST_ACTIVE_PING) ? 'true' : 'false';
237     return sendTelemetryReport({
238         api: apiInstance,
239         measurementGroup: TelemetryMeasurementGroups.driveWebActions,
240         event: TelemetryDriveWebFeature.actions,
241         values: {
242             count,
243         },
244         dimensions: {
245             isLoggedIn: window.location.pathname.startsWith('/urls') ? loggedInRecently : 'true',
246             name: action,
247         },
248     });
251 const TEN_MINUTES = 10 * 60 * 1000;
253 export async function getTimeBasedHash(interval: number = TEN_MINUTES) {
254     const timeBase = Math.floor(Date.now() / interval) * interval;
256     const encoder = new TextEncoder();
257     const data = encoder.encode(timeBase.toString());
258     const hashBuffer = await CryptoProxy.computeHash({ algorithm: 'SHA256', data });
259     const hashArray = Array.from(new Uint8Array(hashBuffer));
260     const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
262     return hashHex;
266  * Tracks user actions within a specified time window, typically used for flows involving redirections.
268  * This function is useful when you want to log an action only if the user starts at point A
269  * and finishes at point B within a given time window. It's particularly helpful in scenarios
270  * where there are redirections in the middle of a flow and passing query parameters through
271  * all redirections is not feasible.
273  * @example
274  * // Sign-Up Modal flow:
275  * // 1. Sign-Up Modal appears (start A)
276  * // 2. User signs up, pays, redirects
277  * // 3. User goes back to shared-with-me page (end B)
278  * // If this happens within the time window, the flow is logged as completed.
280  * @param {Actions} action - The action to be tracked.
281  * @param {number} [duration=TEN_MINUTES] - The time window for tracking the action, default is 10 minutes.
283  * @returns {Object} An object with start and end methods to initiate and complete the tracking.
284  */
285 export const traceTelemetry = (action: Actions, duration: number = TEN_MINUTES) => {
286     return {
287         start: async () => {
288             localStorageWithExpiry.storeData(`telemetry-trace-${action}`, await getTimeBasedHash(duration), duration);
289         },
290         end: async () => {
291             const data = localStorageWithExpiry.getData(`telemetry-trace-${action}`);
292             if (data && data === (await getTimeBasedHash(duration))) {
293                 countActionWithTelemetry(action);
294             }
295             localStorageWithExpiry.deleteData(`telemetry-trace-${action}`);
296         },
297     };