diff --git a/package.json b/package.json index b1704e43..28b726e2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "homepage": "https://github.com/vector-im/hydrogen-web/#readme", "devDependencies": { + "@matrixdotorg/structured-logviewer": "^0.0.1", "@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/parser": "^4.29.2", "acorn": "^8.6.0", diff --git a/scripts/logviewer/file.js b/scripts/logviewer/file.js deleted file mode 100644 index 64a8422b..00000000 --- a/scripts/logviewer/file.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 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. -*/ - -export function openFile(mimeType = null) { - const input = document.createElement("input"); - input.setAttribute("type", "file"); - input.className = "hidden"; - if (mimeType) { - input.setAttribute("accept", mimeType); - } - const promise = new Promise((resolve, reject) => { - const checkFile = () => { - input.removeEventListener("change", checkFile, true); - const file = input.files[0]; - document.body.removeChild(input); - if (file) { - resolve(file); - } else { - reject(new Error("no file picked")); - } - } - input.addEventListener("change", checkFile, true); - }); - // IE11 needs the input to be attached to the document - document.body.appendChild(input); - input.click(); - return promise; -} - -export function readFileAsText(file) { - const reader = new FileReader(); - const promise = new Promise((resolve, reject) => { - reader.addEventListener("load", evt => resolve(evt.target.result)); - reader.addEventListener("error", evt => reject(evt.target.error)); - }); - reader.readAsText(file); - return promise; -} diff --git a/scripts/logviewer/html.js b/scripts/logviewer/html.js deleted file mode 100644 index a965a6ee..00000000 --- a/scripts/logviewer/html.js +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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. -*/ - -// DOM helper functions - -export function isChildren(children) { - // children should be an not-object (that's the attributes), or a domnode, or an array - return typeof children !== "object" || !!children.nodeType || Array.isArray(children); -} - -export function classNames(obj, value) { - return Object.entries(obj).reduce((cn, [name, enabled]) => { - if (typeof enabled === "function") { - enabled = enabled(value); - } - if (enabled) { - return cn + (cn.length ? " " : "") + name; - } else { - return cn; - } - }, ""); -} - -export function setAttribute(el, name, value) { - if (name === "className") { - name = "class"; - } - if (value === false) { - el.removeAttribute(name); - } else { - if (value === true) { - value = name; - } - el.setAttribute(name, value); - } -} - -export function el(elementName, attributes, children) { - return elNS(HTML_NS, elementName, attributes, children); -} - -export function elNS(ns, elementName, attributes, children) { - if (attributes && isChildren(attributes)) { - children = attributes; - attributes = null; - } - - const e = document.createElementNS(ns, elementName); - - if (attributes) { - for (let [name, value] of Object.entries(attributes)) { - if (name === "className" && typeof value === "object" && value !== null) { - value = classNames(value); - } - setAttribute(e, name, value); - } - } - - if (children) { - if (!Array.isArray(children)) { - children = [children]; - } - for (let c of children) { - if (!c.nodeType) { - c = text(c); - } - e.appendChild(c); - } - } - return e; -} - -export function text(str) { - return document.createTextNode(str); -} - -export const HTML_NS = "http://www.w3.org/1999/xhtml"; -export const SVG_NS = "http://www.w3.org/2000/svg"; - -export const TAG_NAMES = { - [HTML_NS]: [ - "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", - "p", "strong", "em", "span", "img", "section", "main", "article", "aside", - "pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"], - [SVG_NS]: ["svg", "circle"] -}; - -export const tag = {}; - - -for (const [ns, tags] of Object.entries(TAG_NAMES)) { - for (const tagName of tags) { - tag[tagName] = function(attributes, children) { - return elNS(ns, tagName, attributes, children); - } - } -} diff --git a/scripts/logviewer/index.html b/scripts/logviewer/index.html deleted file mode 100644 index 08ba2f3c..00000000 --- a/scripts/logviewer/index.html +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - - - -
- - - - diff --git a/scripts/logviewer/main.js b/scripts/logviewer/main.js deleted file mode 100644 index 3ae860b2..00000000 --- a/scripts/logviewer/main.js +++ /dev/null @@ -1,425 +0,0 @@ -/* -Copyright 2020 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. -*/ - -import {tag as t} from "./html.js"; -import {openFile, readFileAsText} from "./file.js"; - -const main = document.querySelector("main"); - -let selectedItemNode; -let rootItem; -let itemByRef; -let itemsRefFrom; - -const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"]; - -main.addEventListener("click", event => { - if (event.target.classList.contains("toggleExpanded")) { - const li = event.target.parentElement.parentElement; - li.classList.toggle("expanded"); - } else { - // allow clicking any links other than .item in the timeline, like refs - if (event.target.tagName === "A" && !event.target.classList.contains("item")) { - return; - } - const itemNode = event.target.closest(".item"); - if (itemNode) { - // we don't want scroll to jump when clicking - // so prevent default behaviour, and select and push to history manually - event.preventDefault(); - selectNode(itemNode); - history.pushState(null, null, `#${itemNode.id}`); - } - } -}); - -window.addEventListener("hashchange", () => { - const id = window.location.hash.substr(1); - const itemNode = document.getElementById(id); - if (itemNode && itemNode.closest("main")) { - ensureParentsExpanded(itemNode); - selectNode(itemNode); - itemNode.scrollIntoView({behavior: "smooth", block: "nearest"}); - } -}); - -function selectNode(itemNode) { - if (selectedItemNode) { - selectedItemNode.classList.remove("selected"); - } - selectedItemNode = itemNode; - selectedItemNode.classList.add("selected"); - let item = rootItem; - let parent; - const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10)); - for(const i of indices) { - parent = item; - item = itemChildren(item)[i]; - } - showItemDetails(item, parent, selectedItemNode); -} - -function ensureParentsExpanded(itemNode) { - let li = itemNode.parentElement.parentElement; - while (li.tagName === "LI") { - li.classList.add("expanded"); - li = li.parentElement.parentElement; - } -} - -function stringifyItemValue(value) { - if (typeof value === "object" && value !== null) { - return JSON.stringify(value, undefined, 2); - } else { - return value + ""; - } -} - -function showItemDetails(item, parent, itemNode) { - const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none"; - const expandButton = t.button("Expand recursively"); - expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement)); - const start = itemStart(item); - const aside = t.aside([ - t.h3(itemCaption(item)), - t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]), - t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]), - t.p([t.strong("Parent offset: "), parentOffset]), - t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]), - t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]), - t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]), - t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]), - t.p(t.strong("Values:")), - t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => { - let valueNode; - if (key === "ref") { - const refItem = itemByRef.get(value); - if (refItem) { - valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem)); - } else { - valueNode = `unknown ref ${value}`; - } - } else if (key === "refId") { - const refSources = itemsRefFrom.get(value) ?? []; - valueNode = t.div([t.p([`${value}`, t.br(),`Found these references:`]),t.ul(refSources.map(item => { - return t.li(t.a({href: `#${item.id}`}, itemCaption(item))); - }))]); - } else { - valueNode = stringifyItemValue(value); - } - return t.li([ - t.span({className: "key"}, normalizeValueKey(key)), - t.span({className: "value"}, valueNode) - ]); - })), - t.p(expandButton) - ]); - document.querySelector("aside").replaceWith(aside); -} - -function expandResursively(li) { - li.classList.add("expanded"); - const ol = li.querySelector("ol"); - if (ol) { - const len = ol.children.length; - for (let i = 0; i < len; i += 1) { - expandResursively(ol.children[i]); - } - } -} - -document.getElementById("openFile").addEventListener("click", loadFile); - -function getRootItemHeader(prevItem, item) { - if (prevItem) { - const diff = itemStart(item) - itemEnd(prevItem); - if (diff >= 0) { - return `+ ${formatTime(diff)}`; - } else { - const overlap = -diff; - if (overlap >= itemDuration(item)) { - return `ran entirely in parallel with`; - } else { - return `ran ${formatTime(-diff)} in parallel with`; - } - } - } else { - return new Date(itemStart(item)).toString(); - } -} - -async function loadFile() { - const file = await openFile(); - document.getElementById("filename").innerText = file.name; - const json = await readFileAsText(file); - const logs = JSON.parse(json); - logs.items.sort((a, b) => itemStart(a) - itemStart(b)); - rootItem = {c: logs.items}; - itemByRef = new Map(); - itemsRefFrom = new Map(); - preprocessRecursively(rootItem, null, itemByRef, itemsRefFrom, []); - - const fragment = logs.items.reduce((fragment, item, i, items) => { - const prevItem = i === 0 ? null : items[i - 1]; - fragment.appendChild(t.section([ - t.h2(getRootItemHeader(prevItem, item)), - t.div({className: "timeline"}, t.ol(itemToNode(item, [i]))) - ])); - return fragment; - }, document.createDocumentFragment()); - main.replaceChildren(fragment); -} - -// TODO: make this use processRecursively -function preprocessRecursively(item, parentElement, refsMap, refsFromMap, path) { - item.s = (parentElement?.s || 0) + item.s; - if (itemRefSource(item)) { - refsMap.set(itemRefSource(item), item); - } - if (itemRef(item)) { - let refs = refsFromMap.get(itemRef(item)); - if (!refs) { - refs = []; - refsFromMap.set(itemRef(item), refs); - } - refs.push(item); - } - if (itemChildren(item)) { - for (let i = 0; i < itemChildren(item).length; i += 1) { - // do it in advance for a child as we don't want to do it for the rootItem - const child = itemChildren(item)[i]; - const childPath = path.concat(i); - child.id = childPath.join("/"); - preprocessRecursively(child, item, refsMap, refsFromMap, childPath); - } - } -} - -const MS_IN_SEC = 1000; -const MS_IN_MIN = MS_IN_SEC * 60; -const MS_IN_HOUR = MS_IN_MIN * 60; -const MS_IN_DAY = MS_IN_HOUR * 24; -function formatTime(ms) { - let str = ""; - if (ms > MS_IN_DAY) { - const days = Math.floor(ms / MS_IN_DAY); - ms -= days * MS_IN_DAY; - str += `${days}d`; - } - if (ms > MS_IN_HOUR) { - const hours = Math.floor(ms / MS_IN_HOUR); - ms -= hours * MS_IN_HOUR; - str += `${hours}h`; - } - if (ms > MS_IN_MIN) { - const mins = Math.floor(ms / MS_IN_MIN); - ms -= mins * MS_IN_MIN; - str += `${mins}m`; - } - if (ms > MS_IN_SEC) { - const secs = ms / MS_IN_SEC; - str += `${secs.toFixed(2)}s`; - } else if (ms > 0 || !str.length) { - str += `${ms}ms`; - } - return str; -} - -function itemChildren(item) { return item.c; } -function itemStart(item) { return item.s; } -function itemEnd(item) { return item.s + item.d; } -function itemDuration(item) { return item.d; } -function itemValues(item) { return item.v; } -function itemLevel(item) { return item.l; } -function itemLabel(item) { return item.v?.l; } -function itemType(item) { return item.v?.t; } -function itemError(item) { return item.e; } -function itemForcedFinish(item) { return item.f; } -function itemRef(item) { return item.v?.ref; } -function itemRefSource(item) { return item.v?.refId; } -function itemShortErrorMessage(item) { - if (itemError(item)) { - const e = itemError(item); - return e.name || e.stack.substr(0, e.stack.indexOf("\n")); - } -} - -function itemCaption(item) { - if (itemLabel(item) && itemError(item)) { - return `${itemLabel(item)} (${itemShortErrorMessage(item)})`; - } if (itemType(item) === "network") { - return `${itemValues(item)?.method} ${itemValues(item)?.url}`; - } else if (itemLabel(item) && itemValues(item)?.id) { - return `${itemLabel(item)} ${itemValues(item).id}`; - } else if (itemLabel(item) && itemValues(item)?.status) { - return `${itemLabel(item)} (${itemValues(item).status})`; - } else if (itemLabel(item) && itemValues(item)?.type) { - return `${itemLabel(item)} (${itemValues(item)?.type})`; - } else if (itemRef(item)) { - const refItem = itemByRef.get(itemRef(item)); - if (refItem) { - return `ref "${itemCaption(refItem)}"` - } else { - return `unknown ref ${itemRef(item)}` - } - } else { - return itemLabel(item) || itemType(item); - } -} -function normalizeValueKey(key) { - switch (key) { - case "t": return "type"; - case "l": return "label"; - default: return key; - } -} - -// returns the node and the total range (recursively) occupied by the node -function itemToNode(item) { - const hasChildren = !!itemChildren(item)?.length; - const className = { - item: true, - "has-children": hasChildren, - error: itemError(item), - [`type-${itemType(item)}`]: !!itemType(item), - [`level-${itemLevel(item)}`]: true, - }; - - const id = item.id; - let captionNode; - if (itemRef(item)) { - const refItem = itemByRef.get(itemRef(item)); - if (refItem) { - captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))]; - } - } - if (!captionNode) { - captionNode = itemCaption(item); - } - const li = t.li([ - t.div([ - hasChildren ? t.button({className: "toggleExpanded"}) : "", - t.a({className, id, href: `#${id}`}, [ - t.span({class: "caption"}, captionNode), - t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`), - ]) - ]) - ]); - if (itemChildren(item) && itemChildren(item).length) { - li.appendChild(t.ol(itemChildren(item).map(item => { - return itemToNode(item); - }))); - } - return li; -} - -const highlightForm = document.getElementById("highlightForm"); - -highlightForm.addEventListener("submit", evt => { - evt.preventDefault(); - const matchesOutput = document.getElementById("highlightMatches"); - const query = document.getElementById("highlight").value; - if (query) { - matchesOutput.innerText = "Searching…"; - let matches = 0; - processRecursively(rootItem, item => { - let domNode = document.getElementById(item.id); - if (itemMatchesFilter(item, query)) { - matches += 1; - domNode.classList.add("highlighted"); - domNode = domNode.parentElement; - while (domNode.nodeName !== "SECTION") { - if (domNode.nodeName === "LI") { - domNode.classList.add("expanded"); - } - domNode = domNode.parentElement; - } - } else { - domNode.classList.remove("highlighted"); - } - }); - matchesOutput.innerText = `${matches} matches`; - } else { - for (const node of document.querySelectorAll(".highlighted")) { - node.classList.remove("highlighted"); - } - matchesOutput.innerText = ""; - } -}); - -function itemMatchesFilter(item, query) { - if (itemError(item)) { - if (valueMatchesQuery(itemError(item), query)) { - return true; - } - } - return valueMatchesQuery(itemValues(item), query); -} - -function valueMatchesQuery(value, query) { - if (typeof value === "string") { - return value.includes(query); - } else if (typeof value === "object" && value !== null) { - for (const key in value) { - if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) { - return true; - } - } - } else if (typeof value === "number") { - return value.toString().includes(query); - } - return false; -} - -function processRecursively(item, callback, parentItem) { - if (item.id) { - callback(item, parentItem); - } - if (itemChildren(item)) { - for (let i = 0; i < itemChildren(item).length; i += 1) { - // do it in advance for a child as we don't want to do it for the rootItem - const child = itemChildren(item)[i]; - processRecursively(child, callback, item); - } - } -} - -document.getElementById("collapseAll").addEventListener("click", () => { - for (const node of document.querySelectorAll(".expanded")) { - node.classList.remove("expanded"); - } -}); -document.getElementById("hideCollapsed").addEventListener("click", () => { - for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) { - node.closest("section").classList.add("hidden"); - } -}); -document.getElementById("hideHighlightedSiblings").addEventListener("click", () => { - for (const node of document.querySelectorAll(".highlighted")) { - const list = node.closest("ol"); - const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li")); - for (const sibling of siblings) { - if (!sibling.classList.contains("expanded")) { - sibling.classList.add("hidden"); - } - } - } -}); -document.getElementById("showAll").addEventListener("click", () => { - for (const node of document.querySelectorAll(".hidden")) { - node.classList.remove("hidden"); - } -}); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 7464a659..d0f2e91d 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -136,8 +136,14 @@ export class SettingsViewModel extends ViewModel { } async exportLogs() { - const logExport = await this.logger.export(); - this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); + const logs = await this.exportLogsBlob(); + this.platform.saveFileAs(logs, `hydrogen-logs-${this.platform.clock.now()}.json`); + } + + async exportLogsBlob() { + const persister = this.logger.reporters.find(r => typeof r.export === "function"); + const logExport = await persister.export(); + return logExport.asBlob(); } async togglePushNotifications() { diff --git a/src/lib.ts b/src/lib.ts index 1ace99b5..1b91c03e 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -14,6 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +export {Logger} from "./logging/Logger"; +export {IDBLogPersister} from "./logging/IDBLogPersister"; +export {ConsoleReporter} from "./logging/ConsoleReporter"; export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; export {RoomStatus} from "./matrix/room/common"; diff --git a/src/logging/ConsoleLogger.ts b/src/logging/ConsoleReporter.ts similarity index 84% rename from src/logging/ConsoleLogger.ts rename to src/logging/ConsoleReporter.ts index 7a3ae638..328b4c23 100644 --- a/src/logging/ConsoleLogger.ts +++ b/src/logging/ConsoleReporter.ts @@ -13,22 +13,27 @@ 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. */ -import {BaseLogger} from "./BaseLogger"; -import {LogItem} from "./LogItem"; -import type {ILogItem, LogItemValues, ILogExport} from "./types"; -export class ConsoleLogger extends BaseLogger { - _persistItem(item: LogItem): void { - printToConsole(item); +import type {ILogger, ILogItem, LogItemValues, ILogReporter} from "./types"; +import type {LogItem} from "./LogItem"; + +export class ConsoleReporter implements ILogReporter { + private logger?: ILogger; + + reportItem(item: ILogItem): void { + printToConsole(item as LogItem); } - async export(): Promise { - return undefined; + setLogger(logger: ILogger) { + this.logger = logger; } printOpenItems(): void { - for (const item of this._openItems) { - this._persistItem(item); + if (!this.logger) { + return; + } + for (const item of this.logger.getOpenRootItems()) { + this.reportItem(item); } } } diff --git a/src/logging/IDBLogger.ts b/src/logging/IDBLogPersister.ts similarity index 60% rename from src/logging/IDBLogger.ts rename to src/logging/IDBLogPersister.ts index ab9474b0..87996519 100644 --- a/src/logging/IDBLogger.ts +++ b/src/logging/IDBLogPersister.ts @@ -22,36 +22,69 @@ import { iterateCursor, fetchResults, } from "../matrix/storage/idb/utils"; -import {BaseLogger} from "./BaseLogger"; import type {Interval} from "../platform/web/dom/Clock"; import type {Platform} from "../platform/web/Platform.js"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; -import type {ILogItem, ILogExport, ISerializedItem} from "./types"; -import type {LogFilter} from "./LogFilter"; +import type {ILogItem, ILogger, ILogReporter, ISerializedItem} from "./types"; +import {LogFilter} from "./LogFilter"; type QueuedItem = { json: string; id?: number; } -export class IDBLogger extends BaseLogger { - private readonly _name: string; - private readonly _limit: number; +type Options = { + name: string, + flushInterval?: number, + limit?: number, + platform: Platform, + serializedTransformer?: (item: ISerializedItem) => ISerializedItem +} + +export class IDBLogPersister implements ILogReporter { private readonly _flushInterval: Interval; private _queuedItems: QueuedItem[]; + private readonly options: Options; + private logger?: ILogger; - constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) { - super(options); - const {name, flushInterval = 60 * 1000, limit = 3000} = options; - this._name = name; - this._limit = limit; + constructor(options: Options) { + this.options = options; this._queuedItems = this._loadQueuedItems(); // TODO: also listen for unload just in case sync keeps on running after pagehide is fired? window.addEventListener("pagehide", this, false); - this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval); + this._flushInterval = this.options.platform.clock.createInterval( + () => this._tryFlush(), + this.options.flushInterval ?? 60 * 1000 + ); } - // TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush + setLogger(logger: ILogger): void { + this.logger = logger; + } + + reportItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { + const queuedItem = this.prepareItemForQueue(logItem, filter, forced); + if (queuedItem) { + this._queuedItems.push(queuedItem); + } + } + + async export(): Promise { + const db = await this._openDB(); + try { + const txn = db.transaction(["logs"], "readonly"); + const logs = txn.objectStore("logs"); + const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false); + const openItems = this.getSerializedOpenItems(); + const allItems = storedItems.concat(this._queuedItems).concat(openItems); + return new IDBLogExport(allItems, this, this.options.platform); + } finally { + try { + db.close(); + } catch (e) {} + } + } + dispose(): void { window.removeEventListener("pagehide", this, false); this._flushInterval.dispose(); @@ -63,7 +96,7 @@ export class IDBLogger extends BaseLogger { } } - async _tryFlush(): Promise { + private async _tryFlush(): Promise { const db = await this._openDB(); try { const txn = db.transaction(["logs"], "readwrite"); @@ -73,9 +106,10 @@ export class IDBLogger extends BaseLogger { logs.add(i); } const itemCount = await reqAsPromise(logs.count()); - if (itemCount > this._limit) { + const limit = this.options.limit ?? 3000; + if (itemCount > limit) { // delete an extra 10% so we don't need to delete every time we flush - let deleteAmount = (itemCount - this._limit) + Math.round(0.1 * this._limit); + let deleteAmount = (itemCount - limit) + Math.round(0.1 * limit); await iterateCursor(logs.openCursor(), (_, __, cursor) => { cursor.delete(); deleteAmount -= 1; @@ -93,14 +127,16 @@ export class IDBLogger extends BaseLogger { } } - _finishAllAndFlush(): void { - this._finishOpenItems(); - this.log({l: "pagehide, closing logs", t: "navigation"}); + private _finishAllAndFlush(): void { + if (this.logger) { + this.logger.log({l: "pagehide, closing logs", t: "navigation"}); + this.logger.forceFinish(); + } this._persistQueuedItems(this._queuedItems); } - _loadQueuedItems(): QueuedItem[] { - const key = `${this._name}_queuedItems`; + private _loadQueuedItems(): QueuedItem[] { + const key = `${this.options.name}_queuedItems`; try { const json = window.localStorage.getItem(key); if (json) { @@ -113,44 +149,32 @@ export class IDBLogger extends BaseLogger { return []; } - _openDB(): Promise { - return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); + private _openDB(): Promise { + return openDatabase(this.options.name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); } - - _persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { - const serializedItem = logItem.serialize(filter, undefined, forced); + + private prepareItemForQueue(logItem: ILogItem, filter: LogFilter, forced: boolean): QueuedItem | undefined { + let serializedItem = logItem.serialize(filter, undefined, forced); if (serializedItem) { - const transformedSerializedItem = this._serializedTransformer(serializedItem); - this._queuedItems.push({ - json: JSON.stringify(transformedSerializedItem) - }); + if (this.options.serializedTransformer) { + serializedItem = this.options.serializedTransformer(serializedItem); + } + return { + json: JSON.stringify(serializedItem) + }; } } - _persistQueuedItems(items: QueuedItem[]): void { + private _persistQueuedItems(items: QueuedItem[]): void { try { - window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items)); + window.localStorage.setItem(`${this.options.name}_queuedItems`, JSON.stringify(items)); } catch (e) { console.error("Could not persist queued log items in localStorage, they will likely be lost", e); } } - async export(): Promise { - const db = await this._openDB(); - try { - const txn = db.transaction(["logs"], "readonly"); - const logs = txn.objectStore("logs"); - const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false); - const allItems = storedItems.concat(this._queuedItems); - return new IDBLogExport(allItems, this, this._platform); - } finally { - try { - db.close(); - } catch (e) {} - } - } - - async _removeItems(items: QueuedItem[]): Promise { + /** @internal called by ILogExport.removeFromStore */ + async removeItems(items: QueuedItem[]): Promise { const db = await this._openDB(); try { const txn = db.transaction(["logs"], "readwrite"); @@ -173,14 +197,29 @@ export class IDBLogger extends BaseLogger { } catch (e) {} } } + + private getSerializedOpenItems(): QueuedItem[] { + const openItems: QueuedItem[] = []; + if (!this.logger) { + return openItems; + } + const filter = new LogFilter(); + for(const item of this.logger!.getOpenRootItems()) { + const openItem = this.prepareItemForQueue(item, filter, false); + if (openItem) { + openItems.push(openItem); + } + } + return openItems; + } } -class IDBLogExport implements ILogExport { +export class IDBLogExport { private readonly _items: QueuedItem[]; - private readonly _logger: IDBLogger; + private readonly _logger: IDBLogPersister; private readonly _platform: Platform; - constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) { + constructor(items: QueuedItem[], logger: IDBLogPersister, platform: Platform) { this._items = items; this._logger = logger; this._platform = platform; @@ -194,18 +233,23 @@ class IDBLogExport implements ILogExport { * @return {Promise} */ removeFromStore(): Promise { - return this._logger._removeItems(this._items); + return this._logger.removeItems(this._items); } asBlob(): BlobHandle { + const json = this.toJSON(); + const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); + const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); + return blob; + } + + toJSON(): string { const log = { formatVersion: 1, appVersion: this._platform.updateService?.version, items: this._items.map(i => JSON.parse(i.json)) }; const json = JSON.stringify(log); - const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); - const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); - return blob; + return json; } } diff --git a/src/logging/LogItem.ts b/src/logging/LogItem.ts index 216cc6bb..5aaabcc4 100644 --- a/src/logging/LogItem.ts +++ b/src/logging/LogItem.ts @@ -16,7 +16,7 @@ limitations under the License. */ import {LogLevel, LogFilter} from "./LogFilter"; -import type {BaseLogger} from "./BaseLogger"; +import type {Logger} from "./Logger"; import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types"; export class LogItem implements ILogItem { @@ -25,11 +25,11 @@ export class LogItem implements ILogItem { public error?: Error; public end?: number; private _values: LogItemValues; - protected _logger: BaseLogger; + protected _logger: Logger; private _filterCreator?: FilterCreator; private _children?: Array; - constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) { + constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) { this._logger = logger; this.start = logger._now(); // (l)abel @@ -38,7 +38,7 @@ export class LogItem implements ILogItem { this._filterCreator = filterCreator; } - /** start a new root log item and run it detached mode, see BaseLogger.runDetached */ + /** start a new root log item and run it detached mode, see Logger.runDetached */ runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator); } @@ -253,7 +253,7 @@ export class LogItem implements ILogItem { return item; } - get logger(): BaseLogger { + get logger(): Logger { return this._logger; } diff --git a/src/logging/BaseLogger.ts b/src/logging/Logger.ts similarity index 83% rename from src/logging/BaseLogger.ts rename to src/logging/Logger.ts index 21643c48..395181ef 100644 --- a/src/logging/BaseLogger.ts +++ b/src/logging/Logger.ts @@ -17,17 +17,17 @@ limitations under the License. import {LogItem} from "./LogItem"; import {LogLevel, LogFilter} from "./LogFilter"; -import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types"; +import type {ILogger, ILogReporter, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types"; import type {Platform} from "../platform/web/Platform.js"; -export abstract class BaseLogger implements ILogger { +export class Logger implements ILogger { protected _openItems: Set = new Set(); protected _platform: Platform; protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem; + public readonly reporters: ILogReporter[] = []; - constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) { + constructor({platform}) { this._platform = platform; - this._serializedTransformer = serializedTransformer; } log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void { @@ -79,10 +79,10 @@ export abstract class BaseLogger implements ILogger { return this._run(item, callback, logLevel, true, filterCreator); } - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; // we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case. - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void; - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void { + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void; + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void { this._openItems.add(item); const finishItem = () => { @@ -134,7 +134,16 @@ export abstract class BaseLogger implements ILogger { } } - _finishOpenItems() { + addReporter(reporter: ILogReporter): void { + reporter.setLogger(this); + this.reporters.push(reporter); + } + + getOpenRootItems(): Iterable { + return this._openItems; + } + + forceFinish() { for (const openItem of this._openItems) { openItem.forceFinish(); try { @@ -150,19 +159,24 @@ export abstract class BaseLogger implements ILogger { this._openItems.clear(); } - abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void; - - abstract export(): Promise; + /** @internal */ + _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void { + for (var i = 0; i < this.reporters.length; i += 1) { + this.reporters[i].reportItem(item, filter, forced); + } + } // expose log level without needing get level(): typeof LogLevel { return LogLevel; } + /** @internal */ _now(): number { return this._platform.clock.now(); } + /** @internal */ _createRefId(): number { return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER); } @@ -171,7 +185,7 @@ export abstract class BaseLogger implements ILogger { class DeferredPersistRootLogItem extends LogItem { finish() { super.finish(); - (this._logger as BaseLogger)._persistItem(this, undefined, false); + (this._logger as Logger)._persistItem(this, undefined, false); } forceFinish() { diff --git a/src/logging/NullLogger.ts b/src/logging/NullLogger.ts index 835f7314..f6f877b3 100644 --- a/src/logging/NullLogger.ts +++ b/src/logging/NullLogger.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import {LogLevel} from "./LogFilter"; -import type {ILogger, ILogExport, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types"; +import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types"; function noop (): void {} @@ -23,6 +23,18 @@ export class NullLogger implements ILogger { log(): void {} + addReporter() {} + + get reporters(): ReadonlyArray { + return []; + } + + getOpenRootItems(): Iterable { + return []; + } + + forceFinish(): void {} + child(): ILogItem { return this.item; } @@ -43,24 +55,20 @@ export class NullLogger implements ILogger { new Promise(r => r(callback(this.item))).then(noop, noop); return this.item; } - - async export(): Promise { - return undefined; - } - + get level(): typeof LogLevel { return LogLevel; } } export class NullLogItem implements ILogItem { - public readonly logger: ILogger; + public readonly logger: NullLogger; public readonly logLevel: LogLevel; public children?: Array; public values: LogItemValues; public error?: Error; - constructor(logger: ILogger) { + constructor(logger: NullLogger) { this.logger = logger; } diff --git a/src/logging/types.ts b/src/logging/types.ts index 2b1d305d..0ed3b0dc 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -16,7 +16,6 @@ limitations under the License. */ import {LogLevel, LogFilter} from "./LogFilter"; -import type {BaseLogger} from "./BaseLogger"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; export interface ISerializedItem { @@ -40,7 +39,7 @@ export interface ILogItem { readonly level: typeof LogLevel; readonly end?: number; readonly start?: number; - readonly values: LogItemValues; + readonly values: Readonly; wrap(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; /*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */ run(callback: LogCallback): T; @@ -74,14 +73,20 @@ export interface ILogger { wrapOrRun(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; run(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; - export(): Promise; get level(): typeof LogLevel; + getOpenRootItems(): Iterable; + addReporter(reporter: ILogReporter): void; + get reporters(): ReadonlyArray; + /** + * force-finishes any open items and passes them to the reporter, with the forced flag set. + * Good think to do when the page is being closed to not lose any logs. + **/ + forceFinish(): void; } -export interface ILogExport { - get count(): number; - removeFromStore(): Promise; - asBlob(): BlobHandle; +export interface ILogReporter { + setLogger(logger: ILogger): void; + reportItem(item: ILogItem, filter?: LogFilter, forced?: boolean): void; } export type LogItemValues = { diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 7470cf15..628b48ac 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -40,11 +40,15 @@ export type Options = Omit & { clock: Clock }; +function getRoomMemberKey(roomId: string, userId: string) { + return JSON.stringify(roomId)+`,`+JSON.stringify(userId); +} + export class CallHandler { // group calls by call id private readonly _calls: ObservableMap = new ObservableMap(); - // map of userId to set of conf_id's they are in - private memberToCallIds: Map> = new Map(); + // map of `"roomId","userId"` to set of conf_id's they are in + private roomMemberToCallIds: Map> = new Map(); private groupCallOptions: GroupCallOptions; private sessionId = makeId("s"); @@ -98,7 +102,7 @@ export class CallHandler { // } const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember); for (const entry of callsMemberEvents) { - this.handleCallMemberEvent(entry.event, log); + this.handleCallMemberEvent(entry.event, roomId, log); } // TODO: we should be loading the other members as well at some point })); @@ -149,7 +153,7 @@ export class CallHandler { // then update members for (const event of events) { if (event.type === EventType.GroupCallMember) { - this.handleCallMemberEvent(event, log); + this.handleCallMemberEvent(event, room.id, log); } } } @@ -194,8 +198,9 @@ export class CallHandler { } } - private handleCallMemberEvent(event: StateEvent, log: ILogItem) { + private handleCallMemberEvent(event: StateEvent, roomId: string, log: ILogItem) { const userId = event.state_key; + const roomMemberKey = getRoomMemberKey(roomId, userId) const calls = event.content["m.calls"] ?? []; const eventTimestamp = event.origin_server_ts; for (const call of calls) { @@ -205,7 +210,8 @@ export class CallHandler { groupCall?.updateMembership(userId, call, eventTimestamp, log); }; const newCallIdsMemberOf = new Set(calls.map(call => call["m.call_id"])); - let previousCallIdsMemberOf = this.memberToCallIds.get(userId); + let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey); + // remove user as member of any calls not present anymore if (previousCallIdsMemberOf) { for (const previousCallId of previousCallIdsMemberOf) { @@ -216,9 +222,9 @@ export class CallHandler { } } if (newCallIdsMemberOf.size === 0) { - this.memberToCallIds.delete(userId); + this.roomMemberToCallIds.delete(roomMemberKey); } else { - this.memberToCallIds.set(userId, newCallIdsMemberOf); + this.roomMemberToCallIds.set(roomMemberKey, newCallIdsMemberOf); } } } diff --git a/src/matrix/calls/TODO.md b/src/matrix/calls/TODO.md index 728c7dc2..eb9f1f25 100644 --- a/src/matrix/calls/TODO.md +++ b/src/matrix/calls/TODO.md @@ -15,7 +15,7 @@ - DONE: implement renegotiation - DONE: finish session id support - call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid). - - making logging better + - DONE: making logging better - figure out why sometimes leave button does not work - get correct members and avatars in call - improve UI while in a call diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 45f349b4..97efc498 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -129,6 +129,14 @@ export class GroupCall extends EventEmitter<{change: never}> { return this._eventTimestamp; } + /** + * Gives access the log item for this call while joined. + * Can be used for call diagnostics while in the call. + **/ + get logItem(): ILogItem | undefined { + return this.joinedData?.logItem; + } + async join(localMedia: LocalMedia): Promise { if (this._state !== GroupCallState.Created || this.joinedData) { return; @@ -250,8 +258,7 @@ export class GroupCall extends EventEmitter<{change: never}> { /** @internal */ updateCallEvent(callContent: Record, syncLog: ILogItem) { - syncLog.wrap({l: "update call", t: CALL_LOG_TYPE}, log => { - log.set("id", this.id); + syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { this.callContent = callContent; if (this._state === GroupCallState.Creating) { this._state = GroupCallState.Created; @@ -290,7 +297,10 @@ export class GroupCall extends EventEmitter<{change: never}> { } else { if (member && sessionIdChanged) { log.set("removedSessionId", member.sessionId); - member.disconnect(false, log); + const disconnectLogItem = member.disconnect(false); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } this._members.remove(memberKey); member = undefined; } @@ -315,9 +325,7 @@ export class GroupCall extends EventEmitter<{change: never}> { // remove user as member of any calls not present anymore for (const previousDeviceId of previousDeviceIds) { if (!newDeviceIds.has(previousDeviceId)) { - log.wrap({l: "remove device member", id: getMemberKey(userId, previousDeviceId)}, log => { - this.removeMemberDevice(userId, previousDeviceId, log); - }); + this.removeMemberDevice(userId, previousDeviceId, log); } } if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) { @@ -332,7 +340,8 @@ export class GroupCall extends EventEmitter<{change: never}> { syncLog.wrap({ l: "remove call member", t: CALL_LOG_TYPE, - id: this.id + id: this.id, + userId }, log => { for (const deviceId of deviceIds) { this.removeMemberDevice(userId, deviceId, log); @@ -379,7 +388,10 @@ export class GroupCall extends EventEmitter<{change: never}> { disconnect(log: ILogItem) { if (this._state === GroupCallState.Joined) { for (const [,member] of this._members) { - member.disconnect(true, log); + const disconnectLogItem = member.disconnect(true); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } } this._state = GroupCallState.Created; } @@ -391,14 +403,18 @@ export class GroupCall extends EventEmitter<{change: never}> { /** @internal */ private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) { const memberKey = getMemberKey(userId, deviceId); - log.set("id", memberKey); - const member = this._members.get(memberKey); - if (member) { - log.set("leave", true); - this._members.remove(memberKey); - member.disconnect(false, log); - } - this.emitChange(); + log.wrap({l: "remove device member", id: memberKey}, log => { + const member = this._members.get(memberKey); + if (member) { + log.set("leave", true); + this._members.remove(memberKey); + const disconnectLogItem = member.disconnect(false); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } + } + this.emitChange(); + }); } /** @internal */ @@ -479,11 +495,16 @@ export class GroupCall extends EventEmitter<{change: never}> { } private connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) { - const logItem = joinedData.membersLogItem.child({l: "member", id: getMemberKey(member.userId, member.deviceId)}); + const memberKey = getMemberKey(member.userId, member.deviceId); + const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey}); logItem.set("sessionId", member.sessionId); - log.refDetached(logItem); - // Safari can't send a MediaStream to multiple sources, so clone it - member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem); + log.wrap({l: "connect", id: memberKey}, log => { + // Safari can't send a MediaStream to multiple sources, so clone it + const connectItem = member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem); + if (connectItem) { + log.refDetached(connectItem); + } + }) } protected emitChange() { diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts index a22725dd..7b64d277 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -72,6 +72,15 @@ export class Member { private readonly options: Options, ) {} + /** + * Gives access the log item for this item once joined to the group call. + * The signalling for this member will be log in this item. + * Can be used for call diagnostics while in the call. + **/ + get logItem(): ILogItem | undefined { + return this.connection?.logItem; + } + get remoteMedia(): RemoteMedia | undefined { return this.connection?.peerCall?.remoteMedia; } @@ -110,15 +119,18 @@ export class Member { } /** @internal */ - connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem) { + connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem): ILogItem | undefined { if (this.connection) { return; } const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem); this.connection = connection; + let connectLogItem; connection.logItem.wrap("connect", async log => { + connectLogItem = log; await this.callIfNeeded(log); }); + return connectLogItem; } private callIfNeeded(log: ILogItem): Promise { @@ -146,13 +158,14 @@ export class Member { } /** @internal */ - disconnect(hangup: boolean, causeItem: ILogItem) { + disconnect(hangup: boolean): ILogItem | undefined { const {connection} = this; if (!connection) { return; } + let disconnectLogItem; connection.logItem.wrap("disconnect", async log => { - log.refDetached(causeItem); + disconnectLogItem = log; if (hangup) { connection.peerCall?.hangup(CallErrorCode.UserHangup, log); } else { @@ -163,6 +176,7 @@ export class Member { this.connection = undefined; }); connection.logItem.finish(); + return disconnectLogItem; } /** @internal */ @@ -202,7 +216,10 @@ export class Member { if (retryCount <= 3) { await this.callIfNeeded(retryLog); } else { - this.disconnect(false, retryLog); + const disconnectLogItem = this.disconnect(false); + if (disconnectLogItem) { + retryLog.refDetached(disconnectLogItem); + } } }); } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 9a8a19c9..846f7b1c 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -21,8 +21,9 @@ import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionI import {SettingsStorage} from "./dom/SettingsStorage.js"; import {Encoding} from "./utils/Encoding.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; -import {IDBLogger} from "../../logging/IDBLogger"; -import {ConsoleLogger} from "../../logging/ConsoleLogger"; +import {IDBLogPersister} from "../../logging/IDBLogPersister"; +import {ConsoleReporter} from "../../logging/ConsoleReporter"; +import {Logger} from "../../logging/Logger"; import {RootView} from "./ui/RootView.js"; import {Clock} from "./dom/Clock.js"; import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; @@ -128,7 +129,7 @@ function adaptUIOnVisualViewportResize(container) { } export class Platform { - constructor({ container, assetPaths, config, configURL, options = null, cryptoExtras = null }) { + constructor({ container, assetPaths, config, configURL, logger, options = null, cryptoExtras = null }) { this._container = container; this._assetPaths = assetPaths; this._config = config; @@ -137,7 +138,7 @@ export class Platform { this.clock = new Clock(); this.encoding = new Encoding(); this.random = Math.random; - this._createLogger(options?.development); + this.logger = logger ?? this._createLogger(options?.development); this.history = new History(); this.onlineStatus = new OnlineStatus(); this._serviceWorkerHandler = null; @@ -185,6 +186,7 @@ export class Platform { } _createLogger(isDevelopment) { + const logger = new Logger({platform: this}); // Make sure that loginToken does not end up in the logs const transformer = (item) => { if (item.e?.stack) { @@ -192,11 +194,12 @@ export class Platform { } return item; }; + const logPersister = new IDBLogPersister({name: "hydrogen_logs", platform: this, serializedTransformer: transformer}); + logger.addReporter(logPersister); if (isDevelopment) { - this.logger = new ConsoleLogger({platform: this}); - } else { - this.logger = new IDBLogger({name: "hydrogen_logs", platform: this, serializedTransformer: transformer}); + logger.addReporter(new ConsoleReporter()); } + return logger; } get updateService() { @@ -320,24 +323,22 @@ import {LogItem} from "../../logging/LogItem"; export function tests() { return { "loginToken should not be in logs": (assert) => { - const transformer = (item) => { - if (item.e?.stack) { - item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, ""); + const logPersister = Object.create(IDBLogPersister.prototype); + logPersister._queuedItems = []; + logPersister.options = { + serializedTransformer: (item) => { + if (item.e?.stack) { + item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, ""); + } + return item; } - return item; }; - const logger = { - _queuedItems: [], - _serializedTransformer: transformer, - _now: () => {} - }; - logger.persist = IDBLogger.prototype._persistItem.bind(logger); + const logger = { _now() {return 5;} }; const logItem = new LogItem("test", 1, logger); logItem.error = new Error(); logItem.error.stack = "main http://localhost:3000/src/main.js:55\n http://localhost:3000/?loginToken=secret:26" - logger.persist(logItem, null, false); - const item = logger._queuedItems.pop(); - console.log(item); + logPersister.reportItem(logItem, null, false); + const item = logPersister._queuedItems.pop(); assert.strictEqual(item.json.search("secret"), -1); } }; diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 93e44307..f375a4be 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -98,13 +98,18 @@ export class SettingsView extends TemplateView { t.h3("Preferences"), row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), ); + const logButtons = [t.button({onClick: () => vm.exportLogs()}, "Export")]; + if (import.meta.env.DEV) { + logButtons.push(t.button({onClick: () => openLogs(vm)}, "Open logs")); + } settingNodes.push( t.h3("Application"), row(t, vm.i18n`Version`, version), row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), - row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), + row(t, vm.i18n`Debug logs`, logButtons), t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), + t.p([]) ); return t.main({className: "Settings middle"}, [ @@ -136,3 +141,37 @@ export class SettingsView extends TemplateView { })]; } } + + +async function openLogs(vm) { + // Use vite-specific url so this asset doesn't get picked up by vite and included in the production build, + // as opening the logs is only available during dev time, and @matrixdotorg/structured-logviewer is a dev dependency + // This url is what import "@matrixdotorg/structured-logviewer/index.html?url" resolves to with vite. + const win = window.open(`/@fs/${DEFINE_PROJECT_DIR}/node_modules/@matrixdotorg/structured-logviewer/index.html`); + await new Promise((resolve, reject) => { + let shouldSendPings = true; + const cleanup = () => { + shouldSendPings = false; + window.removeEventListener("message", waitForPong); + }; + const waitForPong = event => { + if (event.data.type === "pong") { + cleanup(); + resolve(); + } + }; + const sendPings = async () => { + while (shouldSendPings) { + win.postMessage({type: "ping"}); + await new Promise(rr => setTimeout(rr, 50)); + if (win.closed) { + cleanup(); + } + } + }; + window.addEventListener("message", waitForPong); + sendPings().catch(reject); + }); + const logs = await vm.exportLogsBlob(); + win.postMessage({type: "open", logs: logs.nativeBlob}); +} diff --git a/vite.config.js b/vite.config.js index 87e3d063..97fb8885 100644 --- a/vite.config.js +++ b/vite.config.js @@ -37,6 +37,8 @@ export default defineConfig(({mode}) => { "sw": definePlaceholders }), ], - define: definePlaceholders, + define: Object.assign({ + DEFINE_PROJECT_DIR: JSON.stringify(__dirname) + }, definePlaceholders), }); }); diff --git a/yarn.lock b/yarn.lock index 6405fe4d..cbce8b06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56,6 +56,11 @@ version "3.2.3" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" +"@matrixdotorg/structured-logviewer@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@matrixdotorg/structured-logviewer/-/structured-logviewer-0.0.1.tgz#9c29470b552f874afbb1df16c6e8e9e0c55cbf59" + integrity sha512-IdPYxAFDEoEs2G1ImKCkCxFI3xF1DDctP3N9JOtHRvIPbPPdTT9DyNqKTewCb5zwjNB1mGBrnWyURnHDiOOL3w== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"