Use source loader for email sprite icons
[ProtonMail-WebClient.git] / packages / utils / logs.ts
blobb26160c6fad2de23d245bd5369386adc4a214ad9
1 /* eslint-disable no-console */
2 import { getIsIframe } from '@proton/shared/lib/helpers/browser';
3 import { traceError } from '@proton/shared/lib/helpers/sentry';
5 import { Availability, AvailabilityTypes } from './availability';
7 type DebugLogDetail = {
8     type: string;
9     times: Date[];
12 // This is to be safe and to avoid [object Object] appearing, forces the consumer to make the necessary changes for compatibility
13 interface HasToString {
14     toString: () => string;
17 type Args = (string | number | boolean | HasToString | object | undefined | null)[];
18 type LogTypes = 'error' | 'warn' | 'debug' | 'info';
20 const toString = (args: Args): string => {
21     return args
22         .map((arg: any) => {
23             if (typeof arg === 'object' && arg.toString() === '[object Object]') {
24                 return Object.keys(arg)
25                     .map((key) => {
26                         if (['string', 'number', 'boolean'].includes(typeof arg[key])) {
27                             return `${key}:${arg[key]}`;
28                         }
29                     })
30                     .join(' ');
31             }
32             return arg?.toString();
33         })
34         .join(' ');
37 const report = (tag: string, ...args: Args) => {
38     const isMainFrame = !getIsIframe();
40     if (isMainFrame) {
41         const error = args.find((arg) => arg instanceof Error);
42         traceError(error || new Error(toString(args)), {
43             tags: {
44                 tag,
45             },
46             ...(error && {
47                 extra: {
48                     ...args.filter((arg) => !(arg instanceof Error)),
49                 },
50             }),
51         });
52     } else {
53         // child frames bubble up the report to parent
54         window.parent.postMessage({ type: '@proton/utils/logs:report', tag, args }, '*');
55     }
58 /**
59  * Interface for a logger system that supports different levels of logging.
60  * The logger can work within iframes.
61  * All logs are kept in memory and can be sent to customer support at the choice of the customer.
62  * No private or sensitive information should be ever logged using any of these methods.
63  * PII is what that ties back to the user. Eg: account information, keys, media metadata (filename, size, etc..), network setup, browser setup and so on.
64  */
65 export interface LoggerInterface {
66     /**
67      * Logs debug-level messages, useful for development and troubleshooting.
68      * @param args - The messages or objects to log. Do not include private or sensitive information.
69      */
70     debug(...args: Args): void;
72     /**
73      * Logs informational messages, typically used for general logging of application flow.
74      * @param args - The messages or objects to log. Do not include private or sensitive information.
75      */
76     info(...args: Args): void;
78     /**
79      * Logs warning messages, indicating a potential issue or important situation to monitor.
80      * @param args - The messages or objects to log. Do not include private or sensitive information.
81      */
82     warn(...args: Args): void;
84     /**
85      * Logs error messages, typically used for logging errors and exceptions.
86      * @param args - The messages or objects to log. Do not include private or sensitive information.
87      */
88     error(...args: Args): void;
90     /**
91      * Initiates the download of the logged messages in memory.
92      */
93     downloadLogs(): void;
95     /**
96      * Clears all logged messages from the logger.
97      */
98     clearLogs(): void;
100     setEnabled(enabled: boolean): void;
103 export class Logger implements LoggerInterface {
104     private identifier: string;
106     private stack: [string, DebugLogDetail][] = [];
108     private verbose: boolean = false;
110     private enabled: boolean = true;
112     constructor(
113         identifier: string,
114         debugKey?: string,
115         private limit = 10_000
116     ) {
117         this.identifier = identifier;
118         this.verbose = typeof debugKey !== 'undefined' && localStorage.getItem(debugKey) === 'true';
119         this.listen();
120     }
122     setEnabled(enabled: boolean) {
123         this.enabled = enabled;
124     }
126     public debug(...args: Args): void {
127         if (this.verbose) {
128             console.log(...this.logWithColorParams(), ...args);
129             this.save(toString(args), 'warn');
130         }
131     }
133     public info(...args: Args): void {
134         if (this.enabled) {
135             console.log(...this.logWithColorParams(), ...args);
136             this.save(toString(args), 'info');
137         }
138     }
140     public warn(...args: Args): void {
141         if (this.enabled) {
142             console.warn(...args);
143             this.save(toString(args), 'warn');
144         }
145     }
147     public error(...args: Args): void {
148         if (this.enabled) {
149             console.error(...args);
150             this.save(toString(args), 'error');
151             report(this.identifier, ...args);
152             Availability.mark(AvailabilityTypes.ERROR);
153         }
154     }
156     public downloadLogs(): void {
157         const logData = this.getLogs();
158         if (!logData) {
159             return;
160         }
162         const elm = document.createElement('a');
163         elm.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(logData));
164         elm.setAttribute('download', `proton-${this.identifier}-debug.log`);
165         elm.style.display = 'none';
166         document.body.appendChild(elm);
167         elm.click();
168         document.body.removeChild(elm);
169     }
171     public clearLogs(): void {
172         this.stack.splice(0, this.stack.length);
173     }
175     public getLogs(): string {
176         let buffer = '';
177         for (const [message, details] of this.stack) {
178             buffer += `${JSON.stringify(Object.assign(details, { message }))}\n`;
179         }
180         return buffer;
181     }
183     private save(message: string, type: LogTypes, time = new Date()): void {
184         const [lastMessage, lastDetails] = this.stack.at(this.stack.length - 1) || [];
185         // To avoid logging a lot of the same message we log a timer if previous message is exactly the same
186         if (lastMessage === message && lastDetails) {
187             this.stack.splice(this.stack.length - 1, 1, [
188                 message,
189                 {
190                     type,
191                     times: [...lastDetails.times, time],
192                 },
193             ]);
194         } else {
195             this.stack.push([
196                 message,
197                 {
198                     type,
199                     times: [time],
200                 },
201             ]);
202         }
204         if (this.stack.length > this.limit) {
205             this.stack.shift();
206         }
207     }
209     private logWithColorParams(): string[] {
210         const date = new Date();
211         const timeString = `${date.toLocaleTimeString().replace(' PM', '').replace(' AM', '')}.${date.getMilliseconds()}`;
212         return [`%c${this.identifier}%c${timeString}`, 'color: font-weight: bold; margin-right: 4px', 'color: gray'];
213     }
215     private listen(): void {
216         const isMainFrame = !getIsIframe();
218         window.addEventListener('keydown', (event) => {
219             // Mac/Windows/Linux: Press Ctrl + Shift + H (uppercase)
220             if (event.ctrlKey && event.shiftKey && event.key === 'H') {
221                 // Download logs from current frame
222                 this.downloadLogs();
224                 // If we're in a child frame, we postMessage to the parent
225                 if (!isMainFrame) {
226                     window.parent.postMessage({ type: '@proton/utils/logs:downloadLogs' }, '*');
227                 }
228             }
229         });
231         // For main frame, we listen to child frames padding over the keyboard event
232         if (isMainFrame) {
233             window.addEventListener('message', (event) => {
234                 if (event.data && event.data.type === '@proton/utils/logs:downloadLogs') {
235                     this.downloadLogs();
236                 }
237                 if (event.data && event.data.type === '@proton/utils/logs:report') {
238                     if (
239                         typeof event.data.tag === 'string' &&
240                         event.data.args &&
241                         event.data.args != null &&
242                         typeof event.data.args[Symbol.iterator] === 'function'
243                     ) {
244                         report(event.data.tag, ...event.data.args);
245                     } else {
246                         report(
247                             this.identifier,
248                             new Error('@proton/utils/logs:report message does not contain args or is not spreadable')
249                         );
250                     }
251                 }
252             });
253         }
254     }