signal-desktop/ts/logging/main_process_logging.ts

336 lines
8.6 KiB
TypeScript

// Copyright 2017-2021 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 * as path from 'path';
import * as fs from 'fs';
import { app, ipcMain as ipc } from 'electron';
import pinoms from 'pino-multi-stream';
import pino from 'pino';
import * as mkdirp from 'mkdirp';
import * as _ from 'lodash';
import readFirstLine from 'firstline';
import { read as readLastLines } from 'read-last-lines';
import rimraf from 'rimraf';
import { createStream } from 'rotating-file-stream';
import { Environment, getEnvironment } from '../environment';
import {
LogEntryType,
LogLevel,
cleanArgs,
getLogLevelString,
isLogEntry,
} from './shared';
declare global {
// We want to extend `Console`, so we need an interface.
// eslint-disable-next-line no-restricted-syntax
interface Console {
_log: typeof console.log;
_warn: typeof console.warn;
_error: typeof console.error;
}
}
let globalLogger: undefined | pinoms.Logger;
let shouldRestart = false;
const isRunningFromConsole =
Boolean(process.stdout.isTTY) ||
getEnvironment() === Environment.Test ||
getEnvironment() === Environment.TestLib;
export async function initialize(): Promise<pinoms.Logger> {
if (globalLogger) {
throw new Error('Already called initialize!');
}
const basePath = app.getPath('userData');
const logPath = path.join(basePath, 'logs');
mkdirp.sync(logPath);
try {
await cleanupLogs(logPath);
} catch (error) {
const errorString = `Failed to clean logs; deleting all. Error: ${error.stack}`;
console.error(errorString);
await deleteAllLogs(logPath);
mkdirp.sync(logPath);
// 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 = path.join(logPath, 'main.log');
const stream = createStream(logFile, {
interval: '1d',
rotate: 3,
});
const onClose = () => {
globalLogger = undefined;
if (shouldRestart) {
initialize();
}
};
stream.on('close', onClose);
stream.on('error', onClose);
const streams: pinoms.Streams = [];
streams.push({ stream });
if (isRunningFromConsole) {
streams.push({
level: 'debug' as const,
stream: process.stdout,
});
}
const logger = pinoms({
streams,
timestamp: pino.stdTimeFunctions.isoTime,
});
ipc.on('fetch-log', event => {
fetch(logPath).then(
data => {
try {
event.sender.send('fetched-log', data);
} catch (err: unknown) {
// NOTE(evanhahn): We don't want to send a message to a window that's closed.
// I wanted to use `event.sender.isDestroyed()` but that seems to fail.
// Instead, we attempt the send and catch the failure as best we can.
const hasUserClosedWindow = isProbablyObjectHasBeenDestroyedError(
err
);
if (hasUserClosedWindow) {
logger.info(
'Logs were requested, but it seems the window was closed'
);
} else {
logger.error(
'Problem replying with fetched logs',
err instanceof Error && err.stack ? err.stack : err
);
}
}
},
error => {
logger.error(`Problem loading log from disk: ${error.stack}`);
}
);
});
ipc.on('delete-all-logs', async event => {
// Restart logging when the streams will close
shouldRestart = true;
try {
await deleteAllLogs(logPath);
} catch (error) {
logger.error(`Problem deleting all logs: ${error.stack}`);
}
event.sender.send('delete-all-logs-complete');
});
globalLogger = logger;
return logger;
}
async function deleteAllLogs(logPath: string): Promise<void> {
return new Promise((resolve, reject) => {
rimraf(
logPath,
{
disableGlob: true,
},
error => {
if (error) {
return reject(error);
}
return resolve();
}
);
});
}
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.',
error.stack
);
// delete and re-create the log directory
await deleteAllLogs(logPath);
mkdirp.sync(logPath);
}
}
// 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', e.stack, 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 = fs.readdirSync(logPath);
const paths = files.map(file => path.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) {
fs.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 fs.writeFileSync(file.path, `${text}\n`);
})
)
);
}
// Exported for testing only.
export function fetchLog(logFile: string): Promise<Array<LogEntryType>> {
return new Promise((resolve, reject) => {
fs.readFile(logFile, { encoding: 'utf8' }, (err, text) => {
if (err) {
return reject(err);
}
const lines = _.compact(text.split('\n'));
const data = _.compact(
lines.map(line => {
try {
const result = _.pick(JSON.parse(line), ['level', 'time', 'msg']);
return isLogEntry(result) ? result : null;
} catch (e) {
return null;
}
})
);
return resolve(data);
});
});
}
// Exported for testing only.
export function fetch(logPath: string): Promise<Array<LogEntryType>> {
const files = fs.readdirSync(logPath);
const paths = files.map(file => path.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);
});
}
function logAtLevel(level: LogLevel, ...args: ReadonlyArray<unknown>) {
if (globalLogger) {
const levelString = getLogLevelString(level);
globalLogger[levelString](cleanArgs(args));
} else if (isRunningFromConsole) {
console._log(...args);
}
}
function isProbablyObjectHasBeenDestroyedError(err: unknown): boolean {
return err instanceof Error && err.message === 'Object has been destroyed';
}
// This blows up using mocha --watch, so we ensure it is run just once
if (!console._log) {
console._log = console.log;
console.log = _.partial(logAtLevel, LogLevel.Info);
console._error = console.error;
console.error = _.partial(logAtLevel, LogLevel.Error);
console._warn = console.warn;
console.warn = _.partial(logAtLevel, LogLevel.Warn);
}