[DRVWEB-4108] Additional categories for upload errors
[ProtonMail-WebClient.git] / applications / drive / src / app / store / _uploads / UploadProvider / useUploadMetrics.ts
bloba45c6d287c5f3dee0f525877426dd2eaa739e54e
1 import { useRef } from 'react';
3 import metrics from '@proton/metrics';
4 import {
5     getApiError,
6     getIsNetworkError,
7     getIsOfflineError,
8     getIsTimeoutError,
9     getIsUnreachableError,
10 } from '@proton/shared/lib/api/helpers/apiErrorHelper';
11 import { API_CUSTOM_ERROR_CODES } from '@proton/shared/lib/errors';
13 import { is4xx, is5xx } from '../../../utils/errorHandling/apiErrors';
14 import type { Share } from '../../_shares/interface';
15 import { ShareType } from '../../_shares/interface';
16 import useShareState from '../../_shares/useSharesState';
17 import { isVerificationError } from '../worker/verifier';
18 import type { FileUploadReady } from './interface';
20 // TODO: DRVWEB-4123 Unify Share Types
21 export enum UploadShareType {
22     Own = 'own',
23     Device = 'device',
24     Photo = 'photo',
25     Shared = 'shared',
28 export enum UploadErrorCategory {
29     FreeSpaceExceeded = 'free_space_exceeded',
30     TooManyChildren = 'too_many_children',
31     NetworkError = 'network_error',
32     ServerError = 'server_error',
33     IntegrityError = 'integrity_error',
34     Unknown = 'unknown',
35     RateLimited = 'rate_limited',
36     HTTPClientError = '4xx',
37     HTTPServerError = '5xx',
40 export interface FailedUploadMetadata {
41     shareId: string;
42     numberOfErrors: number;
43     encryptedTotalTransferSize: number;
44     roundedUnencryptedFileSize: number;
47 const IGNORED_ERROR_CATEGORIES_FROM_SUCCESS_RATE = [
48     UploadErrorCategory.FreeSpaceExceeded,
49     UploadErrorCategory.TooManyChildren,
50     UploadErrorCategory.NetworkError,
53 const REPORT_ERROR_USERS_EVERY = 5 * 60 * 1000; // 5 minutes,
55 const ROUND_BYTES = 10000; // For privacy we round file.size metrics to 10k bytes
57 export const getFailedUploadMetadata = (
58     nextFileUpload: FileUploadReady,
59     progresses: {
60         [x: string]: number;
61     }
62 ): FailedUploadMetadata => ({
63     shareId: nextFileUpload.shareId,
64     numberOfErrors: nextFileUpload.numberOfErrors,
65     encryptedTotalTransferSize: Object.values(progresses).reduce((sum, value) => sum + value, 0),
66     roundedUnencryptedFileSize: Math.max(Math.round(nextFileUpload.file.size / ROUND_BYTES) * ROUND_BYTES, ROUND_BYTES),
67 });
69 export default function useUploadMetrics(isPaid: boolean, metricsModule = metrics) {
70     const lastErroringUserReport = useRef(0);
72     // Hack: ideally we should use useShare. But that adds complexity with
73     // promises and need to handle exceptions etc. that are not essential
74     // for metrics, and when file is uploading, we know the share must be
75     // already in cache. (to be continued...)
76     const { getShare } = useShareState();
78     const getShareIdType = (shareId: string): UploadShareType => {
79         const share = getShare(shareId);
80         return getShareType(share);
81     };
83     const uploadSucceeded = (shareId: string, numberOfErrors = 0) => {
84         const shareType = getShareIdType(shareId);
85         const retry = numberOfErrors > 1;
87         metricsModule.drive_upload_success_rate_total.increment({
88             status: 'success',
89             shareType,
90             retry: retry ? 'true' : 'false',
91             initiator: 'explicit',
92         });
93     };
95     const uploadFailed = (failedUploadMetadata: FailedUploadMetadata, error: any) => {
96         const shareType = getShareIdType(failedUploadMetadata.shareId);
97         const errorCategory = getErrorCategory(error);
98         const retry = failedUploadMetadata.numberOfErrors > 1;
100         if (!IGNORED_ERROR_CATEGORIES_FROM_SUCCESS_RATE.includes(errorCategory)) {
101             metricsModule.drive_upload_success_rate_total.increment({
102                 status: 'failure',
103                 shareType,
104                 retry: retry ? 'true' : 'false',
105                 initiator: 'explicit',
106             });
107         }
108         // Type of error
109         metricsModule.drive_upload_errors_total.increment({
110             type: errorCategory,
111             shareType: shareType === UploadShareType.Own ? 'main' : shareType,
112             initiator: 'explicit',
113         });
115         // How many encrypted bytes were sent before it failed
116         metricsModule.drive_upload_errors_transfer_size_histogram.observe({
117             Value: failedUploadMetadata.encryptedTotalTransferSize,
118             Labels: {},
119         });
121         // Rounded unencrypted file size of the file that failed the upload
122         metricsModule.drive_upload_errors_file_size_histogram.observe({
123             Value: failedUploadMetadata.roundedUnencryptedFileSize,
124             Labels: {},
125         });
127         if (Date.now() - lastErroringUserReport.current > REPORT_ERROR_USERS_EVERY) {
128             metricsModule.drive_upload_erroring_users_total.increment({
129                 plan: isPaid ? 'paid' : 'free',
130                 shareType,
131                 initiator: 'explicit',
132             });
134             lastErroringUserReport.current = Date.now();
135         }
136     };
138     return {
139         uploadSucceeded,
140         uploadFailed,
141     };
144 export function getShareType(share?: Share): UploadShareType {
145     // (see above...) But if the share is not there anyway, we need to
146     // still decide about share type. Own shares are always loaded by
147     // default, so we can bet that its not own/device/photo and thus
148     // we can set its shared one.
149     if (!share) {
150         return UploadShareType.Shared;
151     }
153     if (share.type === ShareType.default) {
154         return UploadShareType.Own;
155     } else if (share.type === ShareType.photos) {
156         return UploadShareType.Photo;
157     } else if (share.type === ShareType.device) {
158         return UploadShareType.Device;
159     }
160     return UploadShareType.Shared;
163 export function getErrorCategory(error: any): UploadErrorCategory {
164     const apiError = getApiError(error);
165     const statusCode = apiError?.code || error?.statusCode;
167     if (statusCode === API_CUSTOM_ERROR_CODES.TOO_MANY_CHILDREN) {
168         return UploadErrorCategory.TooManyChildren;
169     } else if (statusCode === API_CUSTOM_ERROR_CODES.FREE_SPACE_EXCEEDED) {
170         return UploadErrorCategory.FreeSpaceExceeded;
171     } else if (getIsUnreachableError(error) || getIsTimeoutError(error)) {
172         return UploadErrorCategory.ServerError;
173     } else if (getIsOfflineError(error) || getIsNetworkError(error)) {
174         return UploadErrorCategory.NetworkError;
175     } else if (isVerificationError(error)) {
176         return UploadErrorCategory.IntegrityError;
177     } else if (error?.statusCode && error?.statusCode === 429) {
178         return UploadErrorCategory.RateLimited;
179     } else if (error?.statusCode && is4xx(error?.statusCode)) {
180         return UploadErrorCategory.HTTPClientError;
181     } else if (error?.statusCode && is5xx(error?.statusCode)) {
182         return UploadErrorCategory.HTTPServerError;
183     }
184     return UploadErrorCategory.Unknown;