mirror of https://github.com/vector-im/riot-web
601 lines
23 KiB
TypeScript
601 lines
23 KiB
TypeScript
/*
|
|
Copyright 2017 OpenMarket Ltd
|
|
Copyright 2018 New Vector Ltd
|
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
// This module contains all the code needed to log the console, persist it to
|
|
// disk and submit bug reports. Rationale is as follows:
|
|
// - Monkey-patching the console is preferable to having a log library because
|
|
// we can catch logs by other libraries more easily, without having to all
|
|
// depend on the same log framework / pass the logger around.
|
|
// - We use IndexedDB to persists logs because it has generous disk space
|
|
// limits compared to local storage. IndexedDB does not work in incognito
|
|
// mode, in which case this module will not be able to write logs to disk.
|
|
// However, the logs will still be stored in-memory, so can still be
|
|
// submitted in a bug report should the user wish to: we can also store more
|
|
// logs in-memory than in local storage, which does work in incognito mode.
|
|
// We also need to handle the case where there are 2+ tabs. Each JS runtime
|
|
// generates a random string which serves as the "ID" for that tab/session.
|
|
// These IDs are stored along with the log lines.
|
|
// - Bug reports are sent as a POST over HTTPS: it purposefully does not use
|
|
// Matrix as bug reports may be made when Matrix is not responsive (which may
|
|
// be the cause of the bug). We send the most recent N MB of UTF-8 log data,
|
|
// starting with the most recent, which we know because the "ID"s are
|
|
// actually timestamps. We then purge the remaining logs. We also do this
|
|
// purge on startup to prevent logs from accumulating.
|
|
|
|
// the frequency with which we flush to indexeddb
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
|
|
|
import { getCircularReplacer } from "../utils/JSON";
|
|
|
|
const FLUSH_RATE_MS = 30 * 1000;
|
|
|
|
/** the length of log data we keep in indexeddb (and include in the reports), if there are more than 24 hours of logs */
|
|
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
|
|
|
|
/** the length of log data we keep in indexeddb (and include in the reports), if there are less than 24 hours of logs */
|
|
const MAX_LOG_SIZE_24H = 1024 * 1024 * 100; // 100 MB
|
|
|
|
type LogFunction = (...args: (Error | DOMException | object | string)[]) => void;
|
|
const consoleFunctionsToLevels = {
|
|
log: "I",
|
|
info: "I",
|
|
warn: "W",
|
|
error: "E",
|
|
debug: "D",
|
|
} as const;
|
|
type LogFunctionName = keyof typeof consoleFunctionsToLevels;
|
|
|
|
// A class which monkey-patches the global console and stores log lines.
|
|
export class ConsoleLogger {
|
|
private logs = "";
|
|
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
|
|
|
|
public monkeyPatch(consoleObj: Console): void {
|
|
// Monkey-patch console logging
|
|
(Object.keys(consoleFunctionsToLevels) as LogFunctionName[]).forEach((fnName: LogFunctionName) => {
|
|
const level = consoleFunctionsToLevels[fnName];
|
|
const originalFn = consoleObj[fnName].bind(consoleObj);
|
|
this.originalFunctions[fnName] = originalFn;
|
|
consoleObj[fnName] = (...args) => {
|
|
this.log(level, ...args);
|
|
originalFn(...args);
|
|
};
|
|
});
|
|
}
|
|
|
|
public bypassRageshake(fnName: LogFunctionName, ...args: (Error | DOMException | object | string)[]): void {
|
|
this.originalFunctions[fnName]?.(...args);
|
|
}
|
|
|
|
public log(level: string, ...args: (Error | DOMException | object | string)[]): void {
|
|
// We don't know what locale the user may be running so use ISO strings
|
|
const ts = new Date().toISOString();
|
|
|
|
// Convert objects and errors to helpful things
|
|
args = args.map((arg) => {
|
|
if (arg instanceof DOMException) {
|
|
return arg.message + ` (${arg.name} | ${arg.code})`;
|
|
} else if (arg instanceof Error) {
|
|
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
|
} else if (typeof arg === "object") {
|
|
return JSON.stringify(arg, getCircularReplacer());
|
|
} else {
|
|
return arg;
|
|
}
|
|
});
|
|
|
|
// Some browsers support string formatting which we're not doing here
|
|
// so the lines are a little more ugly but easy to implement / quick to
|
|
// run.
|
|
// Example line:
|
|
// 2017-01-18T11:23:53.214Z W Failed to set badge count
|
|
let line = `${ts} ${level} ${args.join(" ")}\n`;
|
|
// Do some cleanup
|
|
line = line.replace(/token=[a-zA-Z0-9-]+/gm, "token=xxxxx");
|
|
// Using + really is the quickest way in JS
|
|
// http://jsperf.com/concat-vs-plus-vs-join
|
|
this.logs += line;
|
|
}
|
|
|
|
/**
|
|
* Retrieve log lines to flush to disk.
|
|
* @param {boolean} keepLogs True to not delete logs after flushing.
|
|
* @return {string} \n delimited log lines to flush.
|
|
*/
|
|
public flush(keepLogs?: boolean): string {
|
|
// The ConsoleLogger doesn't care how these end up on disk, it just
|
|
// flushes them to the caller.
|
|
if (keepLogs) {
|
|
return this.logs;
|
|
}
|
|
const logsToFlush = this.logs;
|
|
this.logs = "";
|
|
return logsToFlush;
|
|
}
|
|
}
|
|
|
|
// A class which stores log lines in an IndexedDB instance.
|
|
export class IndexedDBLogStore {
|
|
private id: string;
|
|
private index = 0;
|
|
private db: IDBDatabase | null = null;
|
|
private flushPromise: Promise<void> | null = null;
|
|
private flushAgainPromise: Promise<void> | null = null;
|
|
|
|
public constructor(
|
|
private indexedDB: IDBFactory,
|
|
private logger: ConsoleLogger,
|
|
) {
|
|
this.id = "instance-" + randomString(16);
|
|
}
|
|
|
|
/**
|
|
* @return {Promise} Resolves when the store is ready.
|
|
*/
|
|
public connect(): Promise<void> {
|
|
const req = this.indexedDB.open("logs");
|
|
return new Promise((resolve, reject) => {
|
|
req.onsuccess = () => {
|
|
this.db = req.result;
|
|
// Periodically flush logs to local storage / indexeddb
|
|
window.setInterval(this.flush.bind(this), FLUSH_RATE_MS);
|
|
resolve();
|
|
};
|
|
|
|
req.onerror = () => {
|
|
const err = "Failed to open log database: " + req.error?.name;
|
|
logger.error(err);
|
|
reject(new Error(err));
|
|
};
|
|
|
|
// First time: Setup the object store
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
const logObjStore = db.createObjectStore("logs", {
|
|
keyPath: ["id", "index"],
|
|
});
|
|
// Keys in the database look like: [ "instance-148938490", 0 ]
|
|
// Later on we need to query everything based on an instance id.
|
|
// In order to do this, we need to set up indexes "id".
|
|
logObjStore.createIndex("id", "id", { unique: false });
|
|
|
|
logObjStore.add(this.generateLogEntry(new Date() + " ::: Log database was created."));
|
|
|
|
const lastModifiedStore = db.createObjectStore("logslastmod", {
|
|
keyPath: "id",
|
|
});
|
|
lastModifiedStore.add(this.generateLastModifiedTime());
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Flush logs to disk.
|
|
*
|
|
* There are guards to protect against race conditions in order to ensure
|
|
* that all previous flushes have completed before the most recent flush.
|
|
* Consider without guards:
|
|
* - A calls flush() periodically.
|
|
* - B calls flush() and wants to send logs immediately afterwards.
|
|
* - If B doesn't wait for A's flush to complete, B will be missing the
|
|
* contents of A's flush.
|
|
* To protect against this, we set 'flushPromise' when a flush is ongoing.
|
|
* Subsequent calls to flush() during this period will chain another flush,
|
|
* then keep returning that same chained flush.
|
|
*
|
|
* This guarantees that we will always eventually do a flush when flush() is
|
|
* called.
|
|
*
|
|
* @return {Promise} Resolved when the logs have been flushed.
|
|
*/
|
|
public flush(): Promise<void> {
|
|
// check if a flush() operation is ongoing
|
|
if (this.flushPromise) {
|
|
if (this.flushAgainPromise) {
|
|
// this is the 3rd+ time we've called flush() : return the same promise.
|
|
return this.flushAgainPromise;
|
|
}
|
|
// queue up a flush to occur immediately after the pending one completes.
|
|
this.flushAgainPromise = this.flushPromise
|
|
.then(() => {
|
|
return this.flush();
|
|
})
|
|
.then(() => {
|
|
this.flushAgainPromise = null;
|
|
});
|
|
return this.flushAgainPromise;
|
|
}
|
|
// there is no flush promise or there was but it has finished, so do
|
|
// a brand new one, destroying the chain which may have been built up.
|
|
this.flushPromise = new Promise<void>((resolve, reject) => {
|
|
if (!this.db) {
|
|
// not connected yet or user rejected access for us to r/w to the db.
|
|
reject(new Error("No connected database"));
|
|
return;
|
|
}
|
|
const lines = this.logger.flush();
|
|
if (lines.length === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const txn = this.db.transaction(["logs", "logslastmod"], "readwrite");
|
|
const objStore = txn.objectStore("logs");
|
|
txn.oncomplete = (event) => {
|
|
resolve();
|
|
};
|
|
txn.onerror = () => {
|
|
logger.error("Failed to flush logs : ", txn.error);
|
|
reject(new Error("Failed to write logs: " + txn.error?.message));
|
|
};
|
|
objStore.add(this.generateLogEntry(lines));
|
|
const lastModStore = txn.objectStore("logslastmod");
|
|
lastModStore.put(this.generateLastModifiedTime());
|
|
}).then(() => {
|
|
this.flushPromise = null;
|
|
});
|
|
return this.flushPromise;
|
|
}
|
|
|
|
/**
|
|
* Consume the most recent logs and return them. Older logs which are not
|
|
* returned are deleted at the same time, so this can be called at startup
|
|
* to do house-keeping to keep the logs from growing too large.
|
|
*
|
|
* @return {Promise<Object[]>} Resolves to an array of objects. The array is
|
|
* sorted in time (oldest first) based on when the log file was created (the
|
|
* log ID). The objects have said log ID in an "id" field and "lines" which
|
|
* is a big string with all the new-line delimited logs.
|
|
*/
|
|
public async consume(): Promise<{ lines: string; id: string }[]> {
|
|
const allLogIds = await this.fetchLogIds();
|
|
let removeLogIds: string[] = [];
|
|
const logs: { lines: string; id: string }[] = [];
|
|
let size = 0;
|
|
for (let i = 0; i < allLogIds.length; i++) {
|
|
const instanceId = allLogIds[i];
|
|
const { lines, truncated } = await this.fetchLogs(instanceId, size);
|
|
|
|
// always add the returned logs: fetchLogs will truncate once it hits the size limit,
|
|
// so we'll go over the max but only by one fragment's worth.
|
|
logs.push({ lines, id: instanceId });
|
|
size += lines.length;
|
|
|
|
// If fetchLogs truncated we'll now be at or over the size limit,
|
|
// in which case we should stop and remove the rest of the log files.
|
|
if (truncated) {
|
|
logger.log(
|
|
`rageshake: reached size limit while processing instance ${i + 1}/${
|
|
allLogIds.length
|
|
} (${instanceId}), with ${size} bytes of logs: will drop further instances`,
|
|
);
|
|
// the remaining log IDs should be removed. If we go out of
|
|
// bounds this is just []
|
|
removeLogIds = allLogIds.slice(i + 1);
|
|
break;
|
|
}
|
|
}
|
|
if (removeLogIds.length > 0) {
|
|
logger.log(`rageshake: removing logs: ${removeLogIds}`);
|
|
// Don't await this because it's non-fatal if we can't clean up
|
|
// logs.
|
|
Promise.all(removeLogIds.map((id) => this.deleteLogs(id))).then(
|
|
() => {
|
|
logger.log(`Removed ${removeLogIds.length} old logs.`);
|
|
},
|
|
(err) => {
|
|
logger.error(err);
|
|
},
|
|
);
|
|
}
|
|
return logs;
|
|
}
|
|
|
|
/**
|
|
* Fetch all the application instance names from the database.
|
|
*/
|
|
private fetchLogIds(): Promise<string[]> {
|
|
const db = this.db;
|
|
if (!db) return Promise.reject("DB unavailable");
|
|
|
|
// To gather all the log IDs, query for all records in logslastmod.
|
|
const o = db.transaction("logslastmod", "readonly").objectStore("logslastmod");
|
|
return selectQuery(o, undefined, (cursor) => {
|
|
return {
|
|
id: cursor.value.id,
|
|
ts: cursor.value.ts,
|
|
};
|
|
}).then((res) => {
|
|
// Sort IDs by timestamp (newest first)
|
|
return res
|
|
.sort((a, b) => {
|
|
return b.ts - a.ts;
|
|
})
|
|
.map((a) => a.id);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch logs for a given application instance from the database, stopping once we hit the size limit.
|
|
*
|
|
* @param id - Application instance to fetch logs for.
|
|
* @param sizeSoFar - Amount of logs we have already retrieved from other instances.
|
|
*
|
|
* @returns An object with the properties:
|
|
* * `lines`: the concatenated logs for this ID
|
|
* * `truncated`: whether the output was truncated due to hitting the size limit.
|
|
*/
|
|
private fetchLogs(
|
|
id: string,
|
|
sizeSoFar: number,
|
|
): Promise<{
|
|
lines: string;
|
|
truncated: boolean;
|
|
}> {
|
|
const db = this.db;
|
|
if (!db) return Promise.reject("DB unavailable");
|
|
|
|
const objectStore = db.transaction("logs", "readonly").objectStore("logs");
|
|
|
|
/** Determine whether we should stop collecting logs after this batch.
|
|
*
|
|
* @param sizeSoFar - The total amount of logs collected so far.
|
|
* @param logBatchTimestamp - The timestamp of the most recent batch of logs collected.
|
|
*
|
|
* @returns `true` if we should stop after this batch.
|
|
*/
|
|
function shouldTruncateAfterLogBatch(sizeSoFar: number, logBatchTimestamp: number): boolean {
|
|
// First check if we have exceeded the absolute limit
|
|
if (sizeSoFar >= MAX_LOG_SIZE_24H) {
|
|
return true;
|
|
}
|
|
|
|
// Otherwise, check if the most recent batch is more than 24H old, and we have exceeded the limit for logs over 24H
|
|
if (Date.now() - logBatchTimestamp >= 24 * 3600 * 1000 && sizeSoFar >= MAX_LOG_SIZE) {
|
|
return true;
|
|
}
|
|
|
|
// Otherwise, we're good.
|
|
return false;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const query = objectStore.index("id").openCursor(IDBKeyRange.only(id), "prev");
|
|
let lines = "";
|
|
query.onerror = () => {
|
|
reject(new Error("Query failed: " + query.error?.message));
|
|
};
|
|
query.onsuccess = () => {
|
|
const cursor = query.result;
|
|
if (!cursor) {
|
|
// end of results
|
|
resolve({ lines, truncated: false });
|
|
return;
|
|
}
|
|
const newLines = cursor.value.lines;
|
|
// The query returns log chunks in reverse time order, so prepend this new chunk to the buffer.
|
|
lines = newLines + lines;
|
|
sizeSoFar += newLines.length;
|
|
|
|
// If we have now exceeded the size limit, stop.
|
|
if (shouldTruncateAfterLogBatch(sizeSoFar, cursor.value.ts ?? 0)) {
|
|
resolve({ lines, truncated: true });
|
|
} else {
|
|
cursor.continue();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete logs for a given application instance.
|
|
*
|
|
* @param id - Application instance to delete logs for.
|
|
*/
|
|
private deleteLogs(id: string): Promise<void> {
|
|
const db = this.db;
|
|
if (!db) return Promise.reject("DB unavailable");
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
|
|
const o = txn.objectStore("logs");
|
|
// only load the key path, not the data which may be huge
|
|
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
|
|
query.onsuccess = () => {
|
|
const cursor = query.result;
|
|
if (!cursor) {
|
|
return;
|
|
}
|
|
o.delete(cursor.primaryKey);
|
|
cursor.continue();
|
|
};
|
|
txn.oncomplete = () => {
|
|
resolve();
|
|
};
|
|
txn.onerror = () => {
|
|
reject(new Error("Failed to delete logs for " + `'${id}' : ${query.error?.message}`));
|
|
};
|
|
// delete last modified entries
|
|
const lastModStore = txn.objectStore("logslastmod");
|
|
lastModStore.delete(id);
|
|
});
|
|
}
|
|
|
|
/** Generate the object to be stored in the `logs` store */
|
|
private generateLogEntry(lines: string): { id: string; lines: string; index: number; ts: number } {
|
|
return {
|
|
id: this.id,
|
|
lines: lines,
|
|
index: this.index++,
|
|
/** The timestamp at which the line was *flushed* (not necessarily when it was written). */
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
|
|
private generateLastModifiedTime(): { id: string; ts: number } {
|
|
return {
|
|
id: this.id,
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to collect results from a Cursor and promiseify it.
|
|
* @param {ObjectStore|Index} store The store to perform openCursor on.
|
|
* @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor.
|
|
* @param {Function} resultMapper A function which is repeatedly called with a
|
|
* Cursor.
|
|
* Return the data you want to keep.
|
|
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
|
|
* resultMapper.
|
|
*/
|
|
function selectQuery<T>(
|
|
store: IDBIndex | IDBObjectStore,
|
|
keyRange: IDBKeyRange | undefined,
|
|
resultMapper: (cursor: IDBCursorWithValue) => T,
|
|
): Promise<T[]> {
|
|
const query = store.openCursor(keyRange);
|
|
return new Promise((resolve, reject) => {
|
|
const results: T[] = [];
|
|
query.onerror = () => {
|
|
reject(new Error("Query failed: " + query.error?.message));
|
|
};
|
|
// collect results
|
|
query.onsuccess = () => {
|
|
const cursor = query.result;
|
|
if (!cursor) {
|
|
resolve(results);
|
|
return; // end of results
|
|
}
|
|
results.push(resultMapper(cursor));
|
|
cursor.continue();
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configure rage shaking support for sending bug reports.
|
|
* Modifies globals.
|
|
* @param {boolean} setUpPersistence When true (default), the persistence will
|
|
* be set up immediately for the logs.
|
|
* @return {Promise} Resolves when set up.
|
|
*/
|
|
export function init(setUpPersistence = true): Promise<void> {
|
|
if (global.mx_rage_initPromise) {
|
|
return global.mx_rage_initPromise;
|
|
}
|
|
global.mx_rage_logger = new ConsoleLogger();
|
|
global.mx_rage_logger.monkeyPatch(window.console);
|
|
|
|
// log unhandled rejections in the rageshake
|
|
window.addEventListener("unhandledrejection", (event) => {
|
|
global.mx_rage_logger.log("error", `Unhandled promise rejection: ${event.reason}`);
|
|
});
|
|
|
|
if (setUpPersistence) {
|
|
return tryInitStorage();
|
|
}
|
|
|
|
global.mx_rage_initPromise = Promise.resolve();
|
|
return global.mx_rage_initPromise;
|
|
}
|
|
|
|
/**
|
|
* Try to start up the rageshake storage for logs. If not possible (client unsupported)
|
|
* then this no-ops.
|
|
* @return {Promise} Resolves when complete.
|
|
*/
|
|
export function tryInitStorage(): Promise<void> {
|
|
if (global.mx_rage_initStoragePromise) {
|
|
return global.mx_rage_initStoragePromise;
|
|
}
|
|
|
|
logger.log("Configuring rageshake persistence...");
|
|
|
|
// just *accessing* indexedDB throws an exception in firefox with
|
|
// indexeddb disabled.
|
|
let indexedDB;
|
|
try {
|
|
indexedDB = window.indexedDB;
|
|
} catch (e) {}
|
|
|
|
if (indexedDB) {
|
|
global.mx_rage_store = new IndexedDBLogStore(indexedDB, global.mx_rage_logger);
|
|
global.mx_rage_initStoragePromise = global.mx_rage_store.connect();
|
|
|
|
// Fire off a task in the background which will clean up old logs in the store
|
|
global.mx_rage_initStoragePromise.then(() => {
|
|
global.mx_rage_store.consume().catch((e) => {
|
|
logger.error("Error cleaning up rageshake store", e);
|
|
});
|
|
});
|
|
|
|
return global.mx_rage_initStoragePromise;
|
|
}
|
|
global.mx_rage_initStoragePromise = Promise.resolve();
|
|
return global.mx_rage_initStoragePromise;
|
|
}
|
|
|
|
export function flush(): void {
|
|
if (!global.mx_rage_store) {
|
|
return;
|
|
}
|
|
global.mx_rage_store.flush();
|
|
}
|
|
|
|
/**
|
|
* Clean up old logs.
|
|
*
|
|
* @deprecated There is no need to call this explicitly: it will be done as a side-effect of {@link tryInitStorage},
|
|
* or {@link init} with `setUpPersistence: true`.
|
|
*
|
|
* @return {Promise} Resolves if cleaned logs.
|
|
*/
|
|
export async function cleanup(): Promise<void> {
|
|
if (!global.mx_rage_store) {
|
|
return;
|
|
}
|
|
await global.mx_rage_store.consume();
|
|
}
|
|
|
|
/**
|
|
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
|
*
|
|
* @return {Array<{lines: string, id, string}>} list of log data
|
|
*/
|
|
export async function getLogsForReport(): Promise<{ lines: string; id: string }[]> {
|
|
if (!global.mx_rage_logger) {
|
|
throw new Error("No console logger, did you forget to call init()?");
|
|
}
|
|
// If in incognito mode, store is null, but we still want bug report
|
|
// sending to work going off the in-memory console logs.
|
|
if (global.mx_rage_store) {
|
|
// flush most recent logs
|
|
await global.mx_rage_store.flush();
|
|
return global.mx_rage_store.consume();
|
|
} else {
|
|
return [
|
|
{
|
|
lines: global.mx_rage_logger.flush(true),
|
|
id: "-",
|
|
},
|
|
];
|
|
}
|
|
}
|