Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / sentry.ts
blobd56a7bf7a0d3f640dce6458769c4728638cf7cac
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) => host.endsWith('.proton.me') || host === VPN_HOSTNAME;
93 const getDefaultSentryConfig = ({ APP_VERSION, COMMIT }: ProtonConfig): SentryConfig => {
94     const { host } = window.location;
95     return {
96         host,
97         release: isProduction(host) ? APP_VERSION : COMMIT,
98         environment: host.split('.').splice(1).join('.'),
99     };
102 const getDefaultDenyUrls = (): SentryDenyUrls => {
103     return [
104         // Google Adsense
105         /pagead\/js/i,
106         // Facebook flakiness
107         /graph\.facebook\.com/i,
108         // Facebook blocked
109         /connect\.facebook\.net\/en_US\/all\.js/i,
110         // Woopra flakiness
111         /eatdifferent\.com\.woopra-ns\.com/i,
112         /static\.woopra\.com\/js\/woopra\.js/i,
113         // Chrome extensions
114         /extensions\//i,
115         /chrome:\/\//i,
116         /chrome-extension:\/\//i,
117         /moz-extension:\/\//i,
118         /webkit-masked-url:\/\//i,
119         // Other plugins
120         /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
121         /webappstoolbarba\.texthelp\.com\//i,
122         /metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
123     ];
126 const getDefaultIgnoreErrors = (): SentryIgnoreErrors => {
127     return [
128         // Ignore random plugins/extensions
129         'top.GLOBALS',
130         'canvas.contentDocument',
131         'MyApp_RemoveAllHighlights',
132         'atomicFindClose',
133         // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
134         'conduitPage',
135         // https://bugzilla.mozilla.org/show_bug.cgi?id=1678243
136         'XDR encoding failure',
137         'Request timed out',
138         'No network connection',
139         'Failed to fetch',
140         'Load failed',
141         'NetworkError when attempting to fetch resource.',
142         'webkitExitFullScreen', // Bug in Firefox for iOS.
143         'InactiveSession',
144         'InvalidStateError', // Ignore Pale Moon throwing InvalidStateError trying to use idb
145         'UnhandledRejection', // Happens too often in extensions and we have lints for that, so should be safe to ignore.
146         /chrome-extension/,
147         /moz-extension/,
148         'TransferCancel', // User action to interrupt upload or download in Drive.
149         'UploadConflictError', // User uploading the same file again in Drive.
150         'UploadUserError', // Upload error on user's side in Drive.
151         'ValidationError', // Validation error on user's side in Drive.
152         'ChunkLoadError', // WebPack loading source code.
153         /ResizeObserver loop/, // Chromium bug https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded
154         // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
155         'originalCreateNotification',
156         'http://tt.epicplay.com',
157         "Can't find variable: ZiteReader",
158         'jigsaw is not defined',
159         'ComboSearch is not defined',
160         'http://loading.retry.widdit.com/',
161         // Facebook borked
162         'fb_xd_fragment',
163         // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to reduce this. (thanks @acdha)
164         // See http://stackoverflow.com/questions/4113268/how-to-stop-javascript-injection-from-vodafone-proxy
165         'bmi_SafeAddOnload',
166         'EBCallBackMessageReceived',
167         // Avast extension error
168         '_avast_submit',
169         'AbortError',
170         /unleash/i,
171         /Unexpected EOF/i,
172     ];
175 function main({
176     UID,
177     config,
178     sessionTracking = false,
179     sentryConfig = getDefaultSentryConfig(config),
180     ignore = ({ host }) => isLocalhost(host),
181     denyUrls = getDefaultDenyUrls(),
182     ignoreErrors = getDefaultIgnoreErrors(),
183 }: SentryOptions) {
184     const { SENTRY_DSN, SENTRY_DESKTOP_DSN, APP_VERSION } = config;
185     const sentryDSN = isElectronApp ? SENTRY_DESKTOP_DSN || SENTRY_DSN : SENTRY_DSN;
186     const { host, release, environment } = sentryConfig;
188     // No need to configure it if we don't load the DSN
189     if (!sentryDSN || ignore(sentryConfig)) {
190         return;
191     }
193     setUID(UID);
195     // Assumes sentryDSN is: https://111b3eeaaec34cae8e812df705690a36@sentry/11
196     // To get https://111b3eeaaec34cae8e812df705690a36@protonmail.com/api/core/v4/reports/sentry/11
197     const dsn = sentryDSN.replace('sentry', `${host}/api/core/v4/reports/sentry`);
199     init({
200         dsn,
201         release,
202         environment,
203         normalizeDepth: 5,
204         transport: makeProtonFetchTransport,
205         autoSessionTracking: sessionTracking,
206         // do not log calls to console.log, console.error, etc.
207         integrations: [
208             new SentryIntegrations.Breadcrumbs({
209                 console: false,
210             }),
211         ],
212         // Disable client reports. Client reports are used by sentry to retry events that failed to send on visibility change.
213         // Unfortunately Sentry does not use the custom transport for those, and thus fails to add the headers the API requires.
214         sendClientReports: false,
215         beforeSend(event, hint) {
216             const error = hint?.originalException as any;
217             const stack = typeof error === 'string' ? error : error?.stack;
218             // Filter out broken ferdi errors
219             if (stack && stack.match(/ferdi|franz/i)) {
220                 return null;
221             }
223             // Not interested in uncaught API errors, or known errors
224             if (error instanceof ApiError || error?.trace === false) {
225                 return null;
226             }
228             if (!context.enabled) {
229                 return null;
230             }
232             // Remove the hash from the request URL and navigation breadcrumbs to avoid
233             // leaking the search parameters of encrypted searches
234             if (event.request && event.request.url) {
235                 [event.request.url] = event.request.url.split('#');
236             }
237             if (event.breadcrumbs) {
238                 event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => {
239                     if (breadcrumb.category === 'navigation' && breadcrumb.data) {
240                         [breadcrumb.data.from] = breadcrumb.data.from.split('#');
241                         [breadcrumb.data.to] = breadcrumb.data.to.split('#');
242                     }
243                     return breadcrumb;
244                 });
245             }
247             return event;
248         },
249         // Some ignoreErrors and denyUrls are taken from this gist: https://gist.github.com/Chocksy/e9b2cdd4afc2aadc7989762c4b8b495a
250         // This gist is suggested in the Sentry documentation: https://docs.sentry.io/clients/javascript/tips/#decluttering-sentry
251         ignoreErrors,
252         denyUrls,
253     });
255     configureScope((scope) => {
256         scope.setTag('appVersion', APP_VERSION);
257     });
260 export const traceError = (...args: Parameters<typeof captureException>) => {
261     if (!isLocalhost(window.location.host)) {
262         captureException(...args);
263         Availability.mark(AvailabilityTypes.SENTRY);
264     }
267 export const captureMessage = (...args: Parameters<typeof sentryCaptureMessage>) => {
268     if (!isLocalhost(window.location.host)) {
269         sentryCaptureMessage(...args);
270     }
273 type MailInitiative = 'drawer-security-center' | 'composer' | 'assistant';
274 export type SentryInitiative = MailInitiative;
275 type CaptureExceptionArgs = Parameters<typeof captureException>;
278  * Capture error with an additional initiative tag
279  * @param initiative
280  * @param error
281  */
282 export const traceInitiativeError = (initiative: MailInitiative, error: CaptureExceptionArgs[0]) => {
283     if (!isLocalhost(window.location.host)) {
284         captureException(error, {
285             tags: {
286                 initiative,
287             },
288         });
289     }
293  * Capture message with an additional initiative tag
294  * @param initiative
295  * @param error
296  */
297 export const captureInitiativeMessage: (initiative: SentryInitiative, message: string) => void = (
298     initiative,
299     message
300 ) => {
301     captureMessage(message, {
302         tags: {
303             initiative,
304         },
305     });
308 export default main;