Show additional data in debug log header

This commit is contained in:
Evan Hahn 2021-07-30 11:43:16 -05:00 committed by GitHub
parent 03874a788f
commit 689542a9b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 278 additions and 89 deletions

View file

@ -1201,7 +1201,7 @@ app.on('ready', async () => {
installPermissionsHandler({ session, userConfig }); installPermissionsHandler({ session, userConfig });
logger = await logging.initialize(); logger = await logging.initialize(getMainWindow);
logger.info('app ready'); logger.info('app ready');
logger.info(`starting version ${packageJson.version}`); logger.info(`starting version ${packageJson.version}`);

View file

@ -169,6 +169,34 @@ try {
window.updateTrayIcon = unreadCount => window.updateTrayIcon = unreadCount =>
ipc.send('update-tray-icon', unreadCount); ipc.send('update-tray-icon', unreadCount);
ipc.on('additional-log-data-request', async event => {
const ourConversation = window.ConversationController.getOurConversation();
const ourCapabilities = ourConversation
? ourConversation.get('capabilities')
: undefined;
const remoteConfig = window.storage.get('remoteConfig') || {};
let statistics;
try {
statistics = await window.Signal.Data.getStatisticsForLogging();
} catch (error) {
statistics = {};
}
event.sender.send('additional-log-data-response', {
capabilities: ourCapabilities || {},
remoteConfig: _.mapValues(remoteConfig, ({ value }) => value),
statistics,
user: {
deviceId: window.textsecure.storage.user.getDeviceId(),
e164: window.textsecure.storage.user.getNumber(),
uuid: window.textsecure.storage.user.getUuid(),
conversationId: ourConversation && ourConversation.id,
},
});
});
ipc.on('set-up-as-new-device', () => { ipc.on('set-up-as-new-device', () => {
Whisper.events.trigger('setupAsNewDevice'); Whisper.events.trigger('setupAsNewDevice');
}); });

View file

@ -309,9 +309,13 @@ export class ConversationController {
return conversationId; return conversationId;
} }
getOurConversation(): ConversationModel | undefined {
const conversationId = this.getOurConversationId();
return conversationId ? this.get(conversationId) : undefined;
}
getOurConversationOrThrow(): ConversationModel { getOurConversationOrThrow(): ConversationModel {
const conversationId = this.getOurConversationIdOrThrow(); const conversation = this.getOurConversation();
const conversation = this.get(conversationId);
if (!conversation) { if (!conversation) {
throw new Error( throw new Error(
'getOurConversationOrThrow: Failed to fetch our own conversation' 'getOurConversationOrThrow: Failed to fetch our own conversation'

View file

@ -0,0 +1,10 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const formatCountForLogging = (count: number): string => {
if (count === 0 || Number.isNaN(count)) {
return String(count);
}
return `at least ${10 ** Math.floor(Math.log10(count))}`;
};

View file

@ -7,7 +7,7 @@
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { app, ipcMain as ipc } from 'electron'; import { BrowserWindow, app, ipcMain as ipc } from 'electron';
import pinoms from 'pino-multi-stream'; import pinoms from 'pino-multi-stream';
import pino from 'pino'; import pino from 'pino';
import * as mkdirp from 'mkdirp'; import * as mkdirp from 'mkdirp';
@ -21,6 +21,7 @@ import { setLogAtLevel } from './log';
import { Environment, getEnvironment } from '../environment'; import { Environment, getEnvironment } from '../environment';
import { import {
FetchLogIpcData,
LogEntryType, LogEntryType,
LogLevel, LogLevel,
cleanArgs, cleanArgs,
@ -46,7 +47,9 @@ const isRunningFromConsole =
getEnvironment() === Environment.Test || getEnvironment() === Environment.Test ||
getEnvironment() === Environment.TestLib; getEnvironment() === Environment.TestLib;
export async function initialize(): Promise<pinoms.Logger> { export async function initialize(
getMainWindow: () => undefined | BrowserWindow
): Promise<pinoms.Logger> {
if (globalLogger) { if (globalLogger) {
throw new Error('Already called initialize!'); throw new Error('Already called initialize!');
} }
@ -80,7 +83,7 @@ export async function initialize(): Promise<pinoms.Logger> {
globalLogger = undefined; globalLogger = undefined;
if (shouldRestart) { if (shouldRestart) {
initialize(); initialize(getMainWindow);
} }
}; };
@ -102,34 +105,44 @@ export async function initialize(): Promise<pinoms.Logger> {
timestamp: pino.stdTimeFunctions.isoTime, timestamp: pino.stdTimeFunctions.isoTime,
}); });
ipc.on('fetch-log', event => { ipc.on('fetch-log', async event => {
fetch(logPath).then( const mainWindow = getMainWindow();
data => { if (!mainWindow) {
try { logger.info('Logs were requested, but the main window is missing');
event.sender.send('fetched-log', data); return;
} 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. let data: FetchLogIpcData;
// Instead, we attempt the send and catch the failure as best we can. try {
const hasUserClosedWindow = isProbablyObjectHasBeenDestroyedError( const [logEntries, rest] = await Promise.all([
err fetchLogs(logPath),
); fetchAdditionalLogData(mainWindow),
if (hasUserClosedWindow) { ]);
logger.info( data = {
'Logs were requested, but it seems the window was closed' logEntries,
); ...rest,
} else { };
logger.error( } catch (error) {
'Problem replying with fetched logs', logger.error(`Problem loading log data: ${error.stack}`);
err instanceof Error && err.stack ? err.stack : err return;
); }
}
} try {
}, event.sender.send('fetched-log', data);
error => { } catch (err: unknown) {
logger.error(`Problem loading log from disk: ${error.stack}`); // 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
);
} }
); }
}); });
ipc.on('delete-all-logs', async event => { ipc.on('delete-all-logs', async event => {
@ -293,7 +306,7 @@ export function fetchLog(logFile: string): Promise<Array<LogEntryType>> {
} }
// Exported for testing only. // Exported for testing only.
export function fetch(logPath: string): Promise<Array<LogEntryType>> { export function fetchLogs(logPath: string): Promise<Array<LogEntryType>> {
const files = fs.readdirSync(logPath); const files = fs.readdirSync(logPath);
const paths = files.map(file => path.join(logPath, file)); const paths = files.map(file => path.join(logPath, file));
@ -313,6 +326,16 @@ export function fetch(logPath: string): Promise<Array<LogEntryType>> {
}); });
} }
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);
});
});
function logAtLevel(level: LogLevel, ...args: ReadonlyArray<unknown>) { function logAtLevel(level: LogLevel, ...args: ReadonlyArray<unknown>) {
if (globalLogger) { if (globalLogger) {
const levelString = getLogLevelString(level); const levelString = getLogLevelString(level);

View file

@ -19,10 +19,12 @@ import {
import { uploadDebugLogs } from './debuglogs'; import { uploadDebugLogs } from './debuglogs';
import { redactAll } from '../util/privacy'; import { redactAll } from '../util/privacy';
import { import {
FetchLogIpcData,
LogEntryType, LogEntryType,
LogLevel, LogLevel,
cleanArgs, cleanArgs,
getLogLevelString, getLogLevelString,
isFetchLogIpcData,
isLogEntry, isLogEntry,
} from './shared'; } from './shared';
import * as log from './log'; import * as log from './log';
@ -52,21 +54,49 @@ if (window.console) {
// The mechanics of preparing a log for publish // The mechanics of preparing a log for publish
function getHeader() { const headerSectionTitle = (title: string) => `========= ${title} =========`;
let header = window.navigator.userAgent;
header += ` node/${window.getNodeVersion()}`; const headerSection = (
header += ` env/${window.getEnvironment()}`; title: string,
data: Readonly<Record<string, unknown>>
): string => {
const sortedEntries = _.sortBy(Object.entries(data), ([key]) => key);
return [
headerSectionTitle(title),
...sortedEntries.map(
([key, value]) => `${key}: ${redactAll(String(value))}`
),
'',
].join('\n');
};
return header; const getHeader = ({
} capabilities,
remoteConfig,
statistics,
user,
}: Omit<FetchLogIpcData, 'logEntries'>): string =>
[
headerSection('System info', {
Time: Date.now(),
'User agent': window.navigator.userAgent,
'Node version': window.getNodeVersion(),
Environment: window.getEnvironment(),
'App version': window.getVersion(),
}),
headerSection('User info', user),
headerSection('Capabilities', capabilities),
headerSection('Remote config', remoteConfig),
headerSection('Statistics', statistics),
headerSectionTitle('Logs'),
].join('\n');
const getLevel = _.memoize((level: LogLevel): string => { const getLevel = _.memoize((level: LogLevel): string => {
const text = getLogLevelString(level); const text = getLogLevelString(level);
return text.toUpperCase().padEnd(levelMaxLength, ' '); return text.toUpperCase().padEnd(levelMaxLength, ' ');
}); });
function formatLine(mightBeEntry: Readonly<unknown>): string { function formatLine(mightBeEntry: unknown): string {
const entry: LogEntryType = isLogEntry(mightBeEntry) const entry: LogEntryType = isLogEntry(mightBeEntry)
? mightBeEntry ? mightBeEntry
: { : {
@ -84,11 +114,15 @@ function fetch(): Promise<string> {
return new Promise(resolve => { return new Promise(resolve => {
ipc.send('fetch-log'); ipc.send('fetch-log');
ipc.on('fetched-log', (_event, logEntries: unknown) => { ipc.on('fetched-log', (_event, data: unknown) => {
let header: string;
let body: string; let body: string;
if (Array.isArray(logEntries)) { if (isFetchLogIpcData(data)) {
const { logEntries } = data;
header = getHeader(data);
body = logEntries.map(formatLine).join('\n'); body = logEntries.map(formatLine).join('\n');
} else { } else {
header = headerSectionTitle('Partial logs');
const entry: LogEntryType = { const entry: LogEntryType = {
level: LogLevel.Error, level: LogLevel.Error,
msg: 'Invalid IPC data when fetching logs; dropping all logs', msg: 'Invalid IPC data when fetching logs; dropping all logs',
@ -97,7 +131,7 @@ function fetch(): Promise<string> {
body = formatLine(entry); body = formatLine(entry);
} }
const result = `${getHeader()}\n${redactAll(body)}`; const result = `${header}\n${body}`;
resolve(result); resolve(result);
}); });
}); });

View file

@ -2,10 +2,34 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as pino from 'pino'; import * as pino from 'pino';
import { isRecord } from '../util/isRecord';
import { redactAll } from '../util/privacy'; import { redactAll } from '../util/privacy';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { reallyJsonStringify } from '../util/reallyJsonStringify'; import { reallyJsonStringify } from '../util/reallyJsonStringify';
export type FetchLogIpcData = {
capabilities: Record<string, unknown>;
remoteConfig: Record<string, unknown>;
statistics: Record<string, unknown>;
user: Record<string, unknown>;
// We expect `logEntries` to be `Array<LogEntryType>`, but we don't validate that
// upfront—we only validate it when we go to log each line. This improves the
// performance, because we don't have to iterate over every single log entry twice. It
// also means we can log entries if only some of them are invalid.
logEntries: Array<unknown>;
};
// We don't use Zod here because it'd be slow parsing all of the log entries.
// Unfortunately, Zod is a bit slow even with `z.array(z.unknown())`.
export const isFetchLogIpcData = (data: unknown): data is FetchLogIpcData =>
isRecord(data) &&
isRecord(data.capabilities) &&
isRecord(data.remoteConfig) &&
isRecord(data.statistics) &&
isRecord(data.user) &&
Array.isArray(data.logEntries);
// These match [Pino's recommendations][0]. // These match [Pino's recommendations][0].
// [0]: https://getpino.io/#/docs/api?id=loggerlevels-object // [0]: https://getpino.io/#/docs/api?id=loggerlevels-object
export enum LogLevel { export enum LogLevel {
@ -29,7 +53,7 @@ export type LogEntryType = Readonly<{
// whenever we want to send the debug log. We can't use `zod` because it clones // whenever we want to send the debug log. We can't use `zod` because it clones
// the data on successful parse and ruins the performance. // the data on successful parse and ruins the performance.
export const isLogEntry = (data: unknown): data is LogEntryType => { export const isLogEntry = (data: unknown): data is LogEntryType => {
if (data === null || typeof data !== 'object') { if (!isRecord(data)) {
return false; return false;
} }

View file

@ -263,6 +263,8 @@ const dataInterface: ClientInterface = {
insertJob, insertJob,
deleteJob, deleteJob,
getStatisticsForLogging,
// Test-only // Test-only
_getAllMessages, _getAllMessages,
@ -1638,3 +1640,7 @@ async function updateAllConversationColors(
customColorData customColorData
); );
} }
function getStatisticsForLogging(): Promise<Record<string, string>> {
return channels.getStatisticsForLogging();
}

View file

@ -437,6 +437,8 @@ export type DataInterface = {
value: CustomColorType; value: CustomColorType;
} }
) => Promise<void>; ) => Promise<void>;
getStatisticsForLogging(): Promise<Record<string, string>>;
}; };
// The reason for client/server divergence is the need to inject Backbone models and // The reason for client/server divergence is the need to inject Backbone models and

View file

@ -12,6 +12,7 @@ import { join } from 'path';
import mkdirp from 'mkdirp'; import mkdirp from 'mkdirp';
import rimraf from 'rimraf'; import rimraf from 'rimraf';
import SQL, { Database, Statement } from 'better-sqlite3'; import SQL, { Database, Statement } from 'better-sqlite3';
import pProps from 'p-props';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
import { import {
@ -24,6 +25,7 @@ import {
keyBy, keyBy,
last, last,
map, map,
mapValues,
omit, omit,
pick, pick,
} from 'lodash'; } from 'lodash';
@ -37,6 +39,7 @@ import { dropNull } from '../util/dropNull';
import { isNormalNumber } from '../util/isNormalNumber'; import { isNormalNumber } from '../util/isNormalNumber';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { formatCountForLogging } from '../logging/formatCountForLogging';
import { ConversationColorType, CustomColorType } from '../types/Colors'; import { ConversationColorType, CustomColorType } from '../types/Colors';
import { import {
@ -248,6 +251,8 @@ const dataInterface: ServerInterface = {
insertJob, insertJob,
deleteJob, deleteJob,
getStatisticsForLogging,
// Server-only // Server-only
initialize, initialize,
@ -2975,21 +2980,22 @@ async function getAllFromTable<T>(table: string): Promise<Array<T>> {
return rows.map(row => jsonToObject(row.json)); return rows.map(row => jsonToObject(row.json));
} }
function getCountFromTable(table: string): number {
const db = getInstance();
const result: null | number = db
.prepare<EmptyQuery>(`SELECT count(*) from ${table};`)
.pluck(true)
.get();
if (isNumber(result)) {
return result;
}
throw new Error(`getCountFromTable: Unable to get count from table ${table}`);
}
// Conversations // Conversations
async function getConversationCount(): Promise<number> { async function getConversationCount(): Promise<number> {
const db = getInstance(); return getCountFromTable('conversations');
const row = db
.prepare<EmptyQuery>('SELECT count(*) from conversations;')
.get();
if (!row) {
throw new Error(
'getConversationCount: Unable to get count of conversations'
);
}
return row['count(*)'];
} }
function saveConversationSync( function saveConversationSync(
@ -3433,22 +3439,20 @@ async function searchMessagesInConversation(
} }
async function getMessageCount(conversationId?: string): Promise<number> { async function getMessageCount(conversationId?: string): Promise<number> {
const db = getInstance(); if (conversationId === undefined) {
let row: { 'count(*)': number } | undefined; return getCountFromTable('messages');
}
if (conversationId !== undefined) { const db = getInstance();
row = db const row: { 'count(*)': number } | undefined = db
.prepare<Query>( .prepare<Query>(
` `
SELECT count(*) SELECT count(*)
FROM messages FROM messages
WHERE conversationId = $conversationId; WHERE conversationId = $conversationId;
` `
) )
.get({ conversationId }); .get({ conversationId });
} else {
row = db.prepare<EmptyQuery>('SELECT count(*) FROM messages;').get();
}
if (!row) { if (!row) {
throw new Error('getMessageCount: Unable to get count of messages'); throw new Error('getMessageCount: Unable to get count of messages');
@ -4654,14 +4658,7 @@ async function getUnprocessedById(
} }
async function getUnprocessedCount(): Promise<number> { async function getUnprocessedCount(): Promise<number> {
const db = getInstance(); return getCountFromTable('unprocessed');
const row = db.prepare<EmptyQuery>('SELECT count(*) from unprocessed;').get();
if (!row) {
throw new Error('getUnprocessedCount: Unable to get count of unprocessed');
}
return row['count(*)'];
} }
async function getAllUnprocessed(): Promise<Array<UnprocessedType>> { async function getAllUnprocessed(): Promise<Array<UnprocessedType>> {
@ -5197,15 +5194,7 @@ async function deleteStickerPack(packId: string): Promise<Array<string>> {
} }
async function getStickerCount(): Promise<number> { async function getStickerCount(): Promise<number> {
const db = getInstance(); return getCountFromTable('stickers');
const row = db.prepare<EmptyQuery>('SELECT count(*) from stickers;').get();
if (!row) {
throw new Error('getStickerCount: Unable to get count of stickers');
}
return row['count(*)'];
} }
async function getAllStickerPacks(): Promise<Array<StickerPackType>> { async function getAllStickerPacks(): Promise<Array<StickerPackType>> {
const db = getInstance(); const db = getInstance();
@ -5809,6 +5798,16 @@ async function deleteJob(id: string): Promise<void> {
db.prepare<Query>('DELETE FROM jobs WHERE id = $id').run({ id }); db.prepare<Query>('DELETE FROM jobs WHERE id = $id').run({ id });
} }
async function getStatisticsForLogging(): Promise<Record<string, string>> {
const counts = await pProps({
messageCount: getMessageCount(),
conversationCount: getConversationCount(),
sessionCount: getCountFromTable('sessions'),
senderKeyCount: getCountFromTable('senderKeys'),
});
return mapValues(counts, formatCountForLogging);
}
async function updateAllConversationColors( async function updateAllConversationColors(
conversationColor?: ConversationColorType, conversationColor?: ConversationColorType,
customColorData?: { customColorData?: {

View file

@ -0,0 +1,28 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { formatCountForLogging } from '../../logging/formatCountForLogging';
describe('formatCountForLogging', () => {
it('returns "0" if passed zero', () => {
assert.strictEqual(formatCountForLogging(0), '0');
});
it('returns "NaN" if passed NaN', () => {
assert.strictEqual(formatCountForLogging(0 / 0), 'NaN');
});
it('returns "at least X", where X is a power of 10, for other numbers', () => {
assert.strictEqual(formatCountForLogging(1), 'at least 1');
assert.strictEqual(formatCountForLogging(2), 'at least 1');
assert.strictEqual(formatCountForLogging(9), 'at least 1');
assert.strictEqual(formatCountForLogging(10), 'at least 10');
assert.strictEqual(formatCountForLogging(99), 'at least 10');
assert.strictEqual(formatCountForLogging(100), 'at least 100');
assert.strictEqual(formatCountForLogging(999), 'at least 100');
assert.strictEqual(formatCountForLogging(1000), 'at least 1000');
assert.strictEqual(formatCountForLogging(9999), 'at least 1000');
});
});

View file

@ -0,0 +1,26 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { isRecord } from '../../util/isRecord';
describe('isRecord', () => {
it('returns false for primitives', () => {
['hello', 123, BigInt(123), true, undefined, Symbol('test'), null].forEach(
value => {
assert.isFalse(isRecord(value));
}
);
});
it('returns false for arrays', () => {
assert.isFalse(isRecord([]));
});
it('returns true for "plain" objects', () => {
assert.isTrue(isRecord({}));
assert.isTrue(isRecord({ foo: 'bar' }));
assert.isTrue(isRecord(Object.create(null)));
});
});

View file

@ -15,7 +15,7 @@ import {
eliminateOldEntries, eliminateOldEntries,
isLineAfterDate, isLineAfterDate,
fetchLog, fetchLog,
fetch, fetchLogs,
} from '../logging/main_process_logging'; } from '../logging/main_process_logging';
describe('logging', () => { describe('logging', () => {
@ -275,9 +275,9 @@ describe('logging', () => {
}); });
}); });
describe('#fetch', () => { describe('#fetchLogs', () => {
it('returns single entry if no files', () => { it('returns single entry if no files', () => {
return fetch(tmpDir).then(results => { return fetchLogs(tmpDir).then(results => {
expect(results).to.have.length(1); expect(results).to.have.length(1);
expect(results[0].msg).to.match(/Loaded this list/); expect(results[0].msg).to.match(/Loaded this list/);
}); });
@ -296,7 +296,7 @@ describe('logging', () => {
fs.writeFileSync(path.join(tmpDir, 'first.log'), first); fs.writeFileSync(path.join(tmpDir, 'first.log'), first);
fs.writeFileSync(path.join(tmpDir, 'second.log'), second); fs.writeFileSync(path.join(tmpDir, 'second.log'), second);
return fetch(tmpDir).then(results => { return fetchLogs(tmpDir).then(results => {
expect(results).to.have.length(4); expect(results).to.have.length(4);
expect(results[0].msg).to.equal('1'); expect(results[0].msg).to.equal('1');
expect(results[1].msg).to.equal('2'); expect(results[1].msg).to.equal('2');

5
ts/util/isRecord.ts Normal file
View file

@ -0,0 +1,5 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && !Array.isArray(value) && value !== null;