305 lines
		
	
	
	
		
			7.9 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			305 lines
		
	
	
	
		
			7.9 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// Copyright 2017 Signal Messenger, LLC
 | 
						|
// SPDX-License-Identifier: AGPL-3.0-only
 | 
						|
 | 
						|
// NOTE: Temporarily allow `then` until we convert the entire file to `async` / `await`:
 | 
						|
/* eslint-disable more/no-then */
 | 
						|
/* eslint-disable no-console */
 | 
						|
 | 
						|
import { CircularBuffer } from 'cirbuf';
 | 
						|
import type { BrowserWindow } from 'electron';
 | 
						|
import { app, ipcMain as ipc } from 'electron';
 | 
						|
import readFirstLine from 'firstline';
 | 
						|
import lodash from 'lodash';
 | 
						|
import {
 | 
						|
  createReadStream,
 | 
						|
  mkdirSync,
 | 
						|
  readdirSync,
 | 
						|
  unlinkSync,
 | 
						|
  writeFileSync,
 | 
						|
} from 'node:fs';
 | 
						|
import { rm } from 'node:fs/promises';
 | 
						|
import { join } from 'node:path';
 | 
						|
import { read as readLastLines } from 'read-last-lines';
 | 
						|
import split2 from 'split2';
 | 
						|
 | 
						|
import type { LoggerType } from '../types/Logging.js';
 | 
						|
import * as Errors from '../types/errors.js';
 | 
						|
import { createRotatingPinoDest } from '../util/rotatingPinoDest.js';
 | 
						|
import { redactAll } from '../util/privacy.js';
 | 
						|
 | 
						|
import { setPinoDestination, log, setOnLogCallback } from './log.js';
 | 
						|
 | 
						|
import type { FetchLogIpcData, LogEntryType } from './shared.js';
 | 
						|
import { LogLevel, isLogEntry } from './shared.js';
 | 
						|
import { isProduction } from '../util/version.js';
 | 
						|
 | 
						|
const { filter, flatten, map, pick, sortBy } = lodash;
 | 
						|
 | 
						|
const MAX_LOG_LINES = 10_000_000;
 | 
						|
 | 
						|
let isInitialized = false;
 | 
						|
let shouldRestart = false;
 | 
						|
 | 
						|
export async function initialize(
 | 
						|
  getMainWindow: () => undefined | BrowserWindow
 | 
						|
): Promise<LoggerType> {
 | 
						|
  if (isInitialized) {
 | 
						|
    throw new Error('Already called initialize!');
 | 
						|
  }
 | 
						|
  isInitialized = true;
 | 
						|
 | 
						|
  if (!isProduction(app.getVersion())) {
 | 
						|
    setOnLogCallback((level, logLine, msgPrefix) => {
 | 
						|
      if (level >= LogLevel.Error) {
 | 
						|
        getMainWindow()?.webContents.send(
 | 
						|
          'logging-error',
 | 
						|
          `${msgPrefix ? `${msgPrefix}` : ''}${logLine}`
 | 
						|
        );
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  const basePath = app.getPath('userData');
 | 
						|
  const logPath = join(basePath, 'logs');
 | 
						|
  mkdirSync(logPath, { recursive: true });
 | 
						|
 | 
						|
  try {
 | 
						|
    await cleanupLogs(logPath);
 | 
						|
  } catch (error) {
 | 
						|
    const errorString =
 | 
						|
      'Failed to clean logs; deleting all. ' +
 | 
						|
      `Error: ${Errors.toLogFormat(error)}`;
 | 
						|
    console.error(errorString);
 | 
						|
    await deleteAllLogs(logPath);
 | 
						|
    mkdirSync(logPath, { recursive: true });
 | 
						|
 | 
						|
    // If we want this log entry to persist on disk, we need to wait until we've
 | 
						|
    //   set up our logging infrastructure.
 | 
						|
    setTimeout(() => {
 | 
						|
      console.error(errorString);
 | 
						|
    }, 500);
 | 
						|
  }
 | 
						|
 | 
						|
  const logFile = join(logPath, 'main.log');
 | 
						|
  const rotatingStream = createRotatingPinoDest({
 | 
						|
    logFile,
 | 
						|
  });
 | 
						|
 | 
						|
  const onClose = () => {
 | 
						|
    isInitialized = false;
 | 
						|
 | 
						|
    if (shouldRestart) {
 | 
						|
      void initialize(getMainWindow);
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  rotatingStream.on('close', onClose);
 | 
						|
  rotatingStream.on('error', onClose);
 | 
						|
 | 
						|
  setPinoDestination(rotatingStream, redactAll);
 | 
						|
 | 
						|
  ipc.removeHandler('fetch-log');
 | 
						|
  ipc.handle('fetch-log', async () => {
 | 
						|
    const mainWindow = getMainWindow();
 | 
						|
    if (!mainWindow) {
 | 
						|
      log.info('Logs were requested, but the main window is missing');
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    let data: FetchLogIpcData;
 | 
						|
    try {
 | 
						|
      const [logEntries, rest] = await Promise.all([
 | 
						|
        fetchLogs(logPath),
 | 
						|
        fetchAdditionalLogData(mainWindow),
 | 
						|
      ]);
 | 
						|
      data = {
 | 
						|
        logEntries,
 | 
						|
        ...rest,
 | 
						|
      };
 | 
						|
    } catch (error) {
 | 
						|
      log.error(`Problem loading log data: ${Errors.toLogFormat(error)}`);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    return data;
 | 
						|
  });
 | 
						|
 | 
						|
  ipc.removeHandler('delete-all-logs');
 | 
						|
  ipc.handle('delete-all-logs', async () => {
 | 
						|
    // Restart logging when the streams will close
 | 
						|
    shouldRestart = true;
 | 
						|
 | 
						|
    try {
 | 
						|
      await deleteAllLogs(logPath);
 | 
						|
    } catch (error) {
 | 
						|
      log.error(`Problem deleting all logs: ${Errors.toLogFormat(error)}`);
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  return log;
 | 
						|
}
 | 
						|
 | 
						|
async function deleteAllLogs(logPath: string): Promise<void> {
 | 
						|
  await rm(logPath, { recursive: true, force: true });
 | 
						|
}
 | 
						|
 | 
						|
async function cleanupLogs(logPath: string) {
 | 
						|
  const now = new Date();
 | 
						|
  const earliestDate = new Date(
 | 
						|
    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 3)
 | 
						|
  );
 | 
						|
 | 
						|
  try {
 | 
						|
    const remaining = await eliminateOutOfDateFiles(logPath, earliestDate);
 | 
						|
    const files = filter(remaining, file => !file.start && file.end);
 | 
						|
 | 
						|
    if (!files.length) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    await eliminateOldEntries(files, earliestDate);
 | 
						|
  } catch (error) {
 | 
						|
    console.error(
 | 
						|
      'Error cleaning logs; deleting and starting over from scratch.',
 | 
						|
      Errors.toLogFormat(error)
 | 
						|
    );
 | 
						|
 | 
						|
    // delete and re-create the log directory
 | 
						|
    await deleteAllLogs(logPath);
 | 
						|
    mkdirSync(logPath, { recursive: true });
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Exported for testing only.
 | 
						|
export function isLineAfterDate(line: string, date: Readonly<Date>): boolean {
 | 
						|
  if (!line) {
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  try {
 | 
						|
    const data = JSON.parse(line);
 | 
						|
    return new Date(data.time).getTime() > date.getTime();
 | 
						|
  } catch (e) {
 | 
						|
    console.log('error parsing log line', Errors.toLogFormat(e), line);
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Exported for testing only.
 | 
						|
export function eliminateOutOfDateFiles(
 | 
						|
  logPath: string,
 | 
						|
  date: Readonly<Date>
 | 
						|
): Promise<
 | 
						|
  Array<{
 | 
						|
    path: string;
 | 
						|
    start: boolean;
 | 
						|
    end: boolean;
 | 
						|
  }>
 | 
						|
> {
 | 
						|
  const files = readdirSync(logPath);
 | 
						|
  const paths = files.map(file => join(logPath, file));
 | 
						|
 | 
						|
  return Promise.all(
 | 
						|
    map(paths, target =>
 | 
						|
      Promise.all([readFirstLine(target), readLastLines(target, 2)]).then(
 | 
						|
        results => {
 | 
						|
          const start = results[0];
 | 
						|
          const end = results[1].split('\n');
 | 
						|
 | 
						|
          const file = {
 | 
						|
            path: target,
 | 
						|
            start: isLineAfterDate(start, date),
 | 
						|
            end:
 | 
						|
              isLineAfterDate(end[end.length - 1], date) ||
 | 
						|
              isLineAfterDate(end[end.length - 2], date),
 | 
						|
          };
 | 
						|
 | 
						|
          if (!file.start && !file.end) {
 | 
						|
            unlinkSync(file.path);
 | 
						|
          }
 | 
						|
 | 
						|
          return file;
 | 
						|
        }
 | 
						|
      )
 | 
						|
    )
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
// Exported for testing only.
 | 
						|
export async function eliminateOldEntries(
 | 
						|
  files: ReadonlyArray<{ path: string }>,
 | 
						|
  date: Readonly<Date>
 | 
						|
): Promise<void> {
 | 
						|
  await Promise.all(
 | 
						|
    map(files, file =>
 | 
						|
      fetchLog(file.path).then(lines => {
 | 
						|
        const recent = filter(lines, line => new Date(line.time) >= date);
 | 
						|
        const text = map(recent, line => JSON.stringify(line)).join('\n');
 | 
						|
 | 
						|
        return writeFileSync(file.path, `${text}\n`);
 | 
						|
      })
 | 
						|
    )
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
// Exported for testing only.
 | 
						|
export async function fetchLog(logFile: string): Promise<Array<LogEntryType>> {
 | 
						|
  const results = new CircularBuffer<LogEntryType>(MAX_LOG_LINES);
 | 
						|
 | 
						|
  const rawStream = createReadStream(logFile);
 | 
						|
  const jsonStream = rawStream.pipe(
 | 
						|
    split2(line => {
 | 
						|
      try {
 | 
						|
        return JSON.parse(line);
 | 
						|
      } catch (e) {
 | 
						|
        return undefined;
 | 
						|
      }
 | 
						|
    })
 | 
						|
  );
 | 
						|
 | 
						|
  // Propagate fs errors down to the json stream so that for loop below handles
 | 
						|
  // them.
 | 
						|
  rawStream.on('error', error => jsonStream.emit('error', error));
 | 
						|
 | 
						|
  for await (const line of jsonStream) {
 | 
						|
    const result = line && pick(line, ['level', 'time', 'msg']);
 | 
						|
    if (!isLogEntry(result)) {
 | 
						|
      continue;
 | 
						|
    }
 | 
						|
 | 
						|
    results.push(result);
 | 
						|
  }
 | 
						|
 | 
						|
  return results.toArray();
 | 
						|
}
 | 
						|
 | 
						|
// Exported for testing only.
 | 
						|
export function fetchLogs(logPath: string): Promise<Array<LogEntryType>> {
 | 
						|
  const files = readdirSync(logPath);
 | 
						|
  const paths = files.map(file => join(logPath, file));
 | 
						|
 | 
						|
  // creating a manual log entry for the final log result
 | 
						|
  const fileListEntry: LogEntryType = {
 | 
						|
    level: LogLevel.Info,
 | 
						|
    time: new Date().toISOString(),
 | 
						|
    msg: `Loaded this list of log files from logPath: ${files.join(', ')}`,
 | 
						|
  };
 | 
						|
 | 
						|
  return Promise.all(paths.map(fetchLog)).then(results => {
 | 
						|
    const data = flatten(results);
 | 
						|
 | 
						|
    data.push(fileListEntry);
 | 
						|
 | 
						|
    return sortBy(data, logEntry => logEntry.time);
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
export const fetchAdditionalLogData = (
 | 
						|
  mainWindow: BrowserWindow
 | 
						|
): Promise<Omit<FetchLogIpcData, 'logEntries'>> =>
 | 
						|
  new Promise(resolve => {
 | 
						|
    mainWindow.webContents.send('additional-log-data-request');
 | 
						|
    ipc.once('additional-log-data-response', (_event, data) => {
 | 
						|
      resolve(data);
 | 
						|
    });
 | 
						|
  });
 |