xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/custom/browser-log-source.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2023 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15import { LogSource } from '../log-source';
16import { LogEntry, Level } from '../shared/interfaces';
17import { timeFormat } from '../shared/time-format';
18
19export class BrowserLogSource extends LogSource {
20  private originalMethods = {
21    log: console.log,
22    info: console.info,
23    warn: console.warn,
24    error: console.error,
25    debug: console.debug,
26  };
27
28  constructor(sourceName = 'Browser Console') {
29    super(sourceName);
30  }
31
32  private getFileInfo(): string | null {
33    const error = new Error();
34    const stackLines = error.stack?.split('\n').slice(1); // Skip the error message itself
35
36    for (const line of stackLines || []) {
37      const regex = /(?:\()?(.*?)(?:\?[^:]+)?:(\d+):(\d+)(?:\))?/;
38      const match = regex.exec(line);
39
40      if (match) {
41        const [, filePath, lineNumber] = match;
42
43        // Ignore non-.js or .ts files
44        if (
45          (!filePath.endsWith('.js') && !filePath.endsWith('.ts')) ||
46          filePath.includes('browser-log-source.ts')
47        ) {
48          continue;
49        }
50
51        let fileName;
52
53        if (filePath) {
54          const segments = filePath?.split('/');
55          const lastSegment = segments?.pop();
56          const withoutQuery = lastSegment?.split('?')[0];
57          const withoutFragment = withoutQuery?.split('#')[0];
58          fileName = withoutFragment;
59        }
60
61        if (fileName && lineNumber) {
62          return `${fileName}:${lineNumber}`;
63        }
64      }
65    }
66
67    return null;
68  }
69
70  private formatMessage(args: IArguments | string[] | object[]): string {
71    if (args.length > 0) {
72      let msg = String(args[0]);
73      const subs = Array.prototype.slice.call(args, 1);
74      let subIndex = 0;
75      msg = msg.replace(/%s|%d|%i|%f/g, (match) => {
76        if (subIndex < subs.length) {
77          const replacement = subs[subIndex++];
78          switch (match) {
79            case '%s':
80              // Check if replacement is an object and stringify it
81              return typeof replacement === 'object'
82                ? JSON.stringify(replacement)
83                : String(replacement);
84            case '%d':
85            case '%i':
86              return parseInt(replacement, 10);
87            case '%f':
88              return parseFloat(replacement);
89            default:
90              return replacement;
91          }
92        }
93        return match;
94      });
95      // Handle remaining arguments that were not replaced in the string
96      if (subs.length > subIndex) {
97        const remaining = subs
98          .slice(subIndex)
99          .map((arg) =>
100            typeof arg === 'object'
101              ? JSON.stringify(arg, null, 0)
102              : String(arg),
103          )
104          .join(' ');
105        msg += ` ${remaining}`;
106      }
107      return msg;
108    }
109    return '';
110  }
111
112  private publishFormattedLogEntry(
113    level: Level,
114    originalArgs: IArguments | string[] | object[],
115  ): void {
116    const formattedMessage = this.formatMessage(originalArgs);
117    const fileInfo = this.getFileInfo();
118
119    const logEntry: LogEntry = {
120      level: level,
121      timestamp: new Date(),
122      fields: [
123        { key: 'level', value: level },
124        { key: 'time', value: timeFormat.format(new Date()) },
125        { key: 'message', value: formattedMessage },
126        { key: 'file', value: fileInfo || '' },
127      ],
128    };
129
130    this.publishLogEntry(logEntry);
131  }
132
133  start(): void {
134    console.log = (...args: string[] | object[]) => {
135      this.publishFormattedLogEntry(Level.INFO, args);
136      this.originalMethods.log(...args);
137    };
138
139    console.info = (...args: string[] | object[]) => {
140      this.publishFormattedLogEntry(Level.INFO, args);
141      this.originalMethods.info(...args);
142    };
143
144    console.warn = (...args: string[] | object[]) => {
145      this.publishFormattedLogEntry(Level.WARNING, args);
146      this.originalMethods.warn(...args);
147    };
148
149    console.error = (...args: string[] | object[]) => {
150      this.publishFormattedLogEntry(Level.ERROR, args);
151      this.originalMethods.error(...args);
152    };
153
154    console.debug = (...args: string[] | object[]) => {
155      this.publishFormattedLogEntry(Level.DEBUG, args);
156      this.originalMethods.debug(...args);
157    };
158  }
159
160  stop(): void {
161    console.log = this.originalMethods.log;
162    console.info = this.originalMethods.info;
163    console.warn = this.originalMethods.warn;
164    console.error = this.originalMethods.error;
165    console.debug = this.originalMethods.debug;
166  }
167}
168