Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / sentry.ts
blob1eccd398d04ff76f862c97f5983e0b299da40513
1 import type { BrowserOptions } from '@sentry/browser';
2 import {
3     Integrations as SentryIntegrations,
4     captureException,
5     configureScope,
6     init,
7     makeFetchTransport,
8     captureMessage as sentryCaptureMessage,
9 } from '@sentry/browser';
10 import type { BrowserTransportOptions } from '@sentry/browser/types/transports/types';
12 import { Availability, AvailabilityTypes } from '@proton/utils/availability';
14 import { VPN_HOSTNAME } from '../constants';
15 import { ApiError } from '../fetch/ApiError';
16 import { getUIDHeaders } from '../fetch/headers';
17 import type { ProtonConfig } from '../interfaces';
18 import { isElectronApp } from './desktop';
20 type SentryContext = {
21     authHeaders: { [key: string]: string };
22     enabled: boolean;
25 type SentryConfig = {
26     host: string;
27     release: string;
28     environment: string;
31 type SentryDenyUrls = BrowserOptions['denyUrls'];
32 type SentryIgnoreErrors = BrowserOptions['ignoreErrors'];
34 type SentryOptions = {
35     sessionTracking?: boolean;
36     config: ProtonConfig;
37     UID?: string;
38     sentryConfig?: SentryConfig;
39     ignore?: (config: SentryConfig) => boolean;
40     denyUrls?: SentryDenyUrls;
41     ignoreErrors?: SentryIgnoreErrors;
44 const context: SentryContext = {
45     authHeaders: {},
46     enabled: true,
49 export const setUID = (uid: string | undefined) => {
50     context.authHeaders = uid ? getUIDHeaders(uid) : {};
53 export const setSentryEnabled = (enabled: boolean) => {
54     context.enabled = enabled;
57 type FirstFetchParameter = Parameters<typeof fetch>[0];
58 export const getContentTypeHeaders = (input: FirstFetchParameter): HeadersInit => {
59     const url = input.toString();
60     /**
61      * The sentry library does not append the content-type header to requests. The documentation states
62      * what routes accept what content-type. Those content-type headers are also expected through our sentry tunnel.
63      */
64     if (url.includes('/envelope/')) {
65         return { 'content-type': 'application/x-sentry-envelope' };
66     }
68     if (url.includes('/store/')) {
69         return { 'content-type': 'application/json' };
70     }
72     return {};
75 const sentryFetch: typeof fetch = (input, init?) => {
76     return globalThis.fetch(input, {
77         ...init,
78         headers: {
79             ...init?.headers,
80             ...getContentTypeHeaders(input),
81             ...context.authHeaders,
82         },
83     });
86 const makeProtonFetchTransport = (options: BrowserTransportOptions) => {
87     return makeFetchTransport(options, sentryFetch);
90 const isLocalhost = (host: string) => host.startsWith('localhost');
91 export const isProduction = (host: string) =>
92     host.endsWith('.proton.me') || host.endsWith('.protonvpn.com') || host === VPN_HOSTNAME;
94 const getDefaultSentryConfig = ({ APP_VERSION, COMMIT }: ProtonConfig): SentryConfig => {
95     const { host } = window.location;
96     return {
97         host,
98         release: isProduction(host) ? APP_VERSION : COMMIT,
99         environment: host.split('.').splice(1).join('.'),
100     };
103 const getDefaultDenyUrls = (): SentryDenyUrls => {
104     return [
105         // Google Adsense
106         /pagead\/js/i,
107         // Facebook flakiness
108         /graph\.facebook\.com/i,
109         // Facebook blocked
110         /connect\.facebook\.net\/en_US\/all\.js/i,
111         // Woopra flakiness
112         /eatdifferent\.com\.woopra-ns\.com/i,
113         /static\.woopra\.com\/js\/woopra\.js/i,
114         // Chrome extensions
115         /extensions\//i,
116         /chrome:\/\//i,
117         /chrome-extension:\/\//i,
118         /moz-extension:\/\//i,
119         /webkit-masked-url:\/\//i,
120         // Other plugins
121         /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
122         /webappstoolbarba\.texthelp\.com\//i,
123         /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
124     ];
127 const getDefaultIgnoreErrors = (): SentryIgnoreErrors => {
128     return [
129         // Ignore random plugins/extensions
130         'top.GLOBALS',
131         'canvas.contentDocument',
132         'MyApp_RemoveAllHighlights',
133         'atomicFindClose',
134         // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
135         'conduitPage',
136         // https://bugzilla.mozilla.org/show_bug.cgi?id=1678243
137         'XDR encoding failure',
138         'Request timed out',
139         'No network connection',
140         'Failed to fetch',
141         'Load failed',
142         'NetworkError when attempting to fetch resource.',
143         'webkitExitFullScreen', // Bug in Firefox for iOS.
144         'InactiveSession',
145         'InvalidStateError', // Ignore Pale Moon throwing InvalidStateError trying to use idb
146         'UnhandledRejection', // Happens too often in extensions and we have lints for that, so should be safe to ignore.
147         /chrome-extension/,
148         /moz-extension/,
149         'TransferCancel', // User action to interrupt upload or download in Drive.
150         'UploadConflictError', // User uploading the same file again in Drive.
151         'UploadUserError', // Upload error on user's side in Drive.
152         'ValidationError', // Validation error on user's side in Drive.
153         'ChunkLoadError', // WebPack loading source code.
154         /ResizeObserver loop/, // Chromium bug https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded
155         // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
156         'originalCreateNotification',
157         'http://tt.epicplay.com',
158         "Can't find variable: ZiteReader",
159         'jigsaw is not defined',
160         'ComboSearch is not defined',
161         'http://loading.retry.widdit.com/',
162         // Facebook borked
163         'fb_xd_fragment',
164         // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to reduce this. (thanks @acdha)
165         // See http://stackoverflow.com/questions/4113268/how-to-stop-javascript-injection-from-vodafone-proxy
166         'bmi_SafeAddOnload',
167         'EBCallBackMessageReceived',
168         // Avast extension error
169         '_avast_submit',
170         'AbortError',
171         /unleash/i,
172         /Unexpected EOF/i,
173     ];
176 function main({
177     UID,
178     config,
179     sessionTracking = false,
180     sentryConfig = getDefaultSentryConfig(config),
181     ignore = ({ host }) => isLocalhost(host),
182     denyUrls = getDefaultDenyUrls(),
183     ignoreErrors = getDefaultIgnoreErrors(),
184 }: SentryOptions) {
185     const { SENTRY_DSN, SENTRY_DESKTOP_DSN, APP_VERSION } = config;
186     const sentryDSN = isElectronApp ? SENTRY_DESKTOP_DSN || SENTRY_DSN : SENTRY_DSN;
187     const { host, release, environment } = sentryConfig;
189     // No need to configure it if we don't load the DSN
190     if (!sentryDSN || ignore(sentryConfig)) {
191         return;
192     }
194     setUID(UID);
196     // Assumes sentryDSN is: https://111b3eeaaec34cae8e812df705690a36@sentry/11
197     // To get https://111b3eeaaec34cae8e812df705690a36@protonmail.com/api/core/v4/reports/sentry/11
198     const dsn = sentryDSN.replace('sentry', `${host}/api/core/v4/reports/sentry`);
200     init({
201         dsn,
202         release,
203         environment,
204         normalizeDepth: 5,
205         transport: makeProtonFetchTransport,
206         autoSessionTracking: sessionTracking,
207         // do not log calls to console.log, console.error, etc.
208         integrations: [
209             new SentryIntegrations.Breadcrumbs({
210                 console: false,
211             }),
212         ],
213         // Disable client reports. Client reports are used by sentry to retry events that failed to send on visibility change.
214         // Unfortunately Sentry does not use the custom transport for those, and thus fails to add the headers the API requires.
215         sendClientReports: false,
216         beforeSend(event, hint) {
217             const error = hint?.originalException as any;
218             const stack = typeof error === 'string' ? error : error?.stack;
219             // Filter out broken ferdi errors
220             if (stack && stack.match(/ferdi|franz/i)) {
221                 return null;
222             }
224             // Not interested in uncaught API errors, or known errors
225             if (error instanceof ApiError || error?.trace === false) {
226                 return null;
227             }
229             if (!context.enabled) {
230                 return null;
231             }
233             // Remove the hash from the request URL and navigation breadcrumbs to avoid
234             // leaking the search parameters of encrypted searches
235             if (event.request && event.request.url) {
236                 [event.request.url] = event.request.url.split('#');
237             }
238             // keys/all endpoint accepts Email as parameter which is PII.
239             if (event.request && event.request.url) {
240                 [event.request.url] = event.request.url.toLowerCase().split('email');
241             }
242             if (event.breadcrumbs) {
243                 event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
244                     if (breadcrumb.category === 'navigation' && breadcrumb.data) {
245                         [breadcrumb.data.from] = breadcrumb.data.from.split('#');
246                         [breadcrumb.data.to] = breadcrumb.data.to.split('#');
247                     }
249                     // Button titles may contain accidental PII
250                     if (
251                         breadcrumb.category === 'ui.click' &&
252                         breadcrumb.message &&
253                         breadcrumb.message.startsWith('button')
254                     ) {
255                         breadcrumb.message = breadcrumb.message.replace(/\[title=".+?"\]/g, '[title="(Filtered)"]');
256                     }
258                     return breadcrumb;
259                 });
260             }
262             return event;
263         },
264         // Some ignoreErrors and denyUrls are taken from this gist: https://gist.github.com/Chocksy/e9b2cdd4afc2aadc7989762c4b8b495a
265         // This gist is suggested in the Sentry documentation: https://docs.sentry.io/clients/javascript/tips/#decluttering-sentry
266         ignoreErrors,
267         denyUrls,
268     });
270     configureScope((scope) => {
271         scope.setTag('appVersion', APP_VERSION);
272     });
275 export const traceError = (...args: Parameters<typeof captureException>) => {
276     if (!isLocalhost(window.location.host)) {
277         captureException(...args);
278         Availability.mark(AvailabilityTypes.SENTRY);
279     }
282 export const captureMessage = (...args: Parameters<typeof sentryCaptureMessage>) => {
283     if (!isLocalhost(window.location.host)) {
284         sentryCaptureMessage(...args);
285     }
288 type MailInitiative = 'drawer-security-center' | 'composer' | 'assistant' | 'mail-onboarding';
289 type CommonInitiatives = 'post-subscription';
290 export type SentryInitiative = MailInitiative;
291 type CaptureExceptionArgs = Parameters<typeof captureException>;
294  * Capture error with an additional initiative tag
295  * @param initiative
296  * @param error
297  */
298 export const traceInitiativeError = (
299     initiative: MailInitiative | CommonInitiatives,
300     error: CaptureExceptionArgs[0]
301 ) => {
302     if (!isLocalhost(window.location.host)) {
303         captureException(error, {
304             tags: {
305                 initiative,
306             },
307         });
308     }
312  * Capture message with an additional initiative tag
313  * @param initiative
314  * @param error
315  */
316 export const captureInitiativeMessage: (initiative: SentryInitiative, message: string) => void = (
317     initiative,
318     message
319 ) => {
320     captureMessage(message, {
321         tags: {
322             initiative,
323         },
324     });
327 export default main;