From 0fdc6b1c3ae8e41e372c89b3f666b8937428eff1 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 6 May 2022 15:54:45 +0200 Subject: [PATCH 01/14] log both to idb storage and console, include open items in export refactor logging api so a logger has multiple reporters, IDBLogPersister and/or ConsoleReporter. By default, we add the idb persister for production and both for dev You can also inject your own logger when creating the platform now. --- .../session/settings/SettingsViewModel.js | 3 +- src/lib.ts | 3 + .../{ConsoleLogger.ts => ConsoleReporter.ts} | 25 +-- .../{IDBLogger.ts => IDBLogPersister.ts} | 154 +++++++++++------- src/logging/LogItem.ts | 10 +- src/logging/{BaseLogger.ts => Logger.ts} | 38 +++-- src/logging/NullLogger.ts | 16 +- src/logging/types.ts | 19 ++- src/platform/web/Platform.js | 17 +- 9 files changed, 182 insertions(+), 103 deletions(-) rename src/logging/{ConsoleLogger.ts => ConsoleReporter.ts} (84%) rename src/logging/{IDBLogger.ts => IDBLogPersister.ts} (60%) rename src/logging/{BaseLogger.ts => Logger.ts} (83%) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 7464a659..e7990844 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -136,7 +136,8 @@ export class SettingsViewModel extends ViewModel { } async exportLogs() { - const logExport = await this.logger.export(); + const persister = this.logger.reporters.find(r => typeof r.export === "function"); + const logExport = await persister.export(); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } diff --git a/src/lib.ts b/src/lib.ts index a49eacbc..0fc6f539 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..2199b1ff 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.asJSON(); + const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); + const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); + return blob; + } + + asJSON(): 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..9e9b576b 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, LabelOrValues, LogCallback, LogItemValues} from "./types"; function noop (): void {} @@ -23,6 +23,14 @@ export class NullLogger implements ILogger { log(): void {} + addReporter() {} + + getOpenRootItems(): Iterable { + return []; + } + + forceFinish(): void {} + child(): ILogItem { return this.item; } @@ -43,11 +51,7 @@ 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; } 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/platform/web/Platform.js b/src/platform/web/Platform.js index 9a8a19c9..19eef367 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() { From fc08fc3744f2e9e2449d65dba90b2c9021bd807a Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 6 May 2022 16:59:26 +0200 Subject: [PATCH 02/14] always log device removal in same way and prevent call id overwritten --- src/matrix/calls/group/GroupCall.ts | 53 ++++++++++++++++++----------- src/matrix/calls/group/Member.ts | 16 ++++++--- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 4f626f7b..2747d960 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -239,8 +239,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; @@ -274,7 +273,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; } @@ -299,9 +301,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)) { @@ -316,7 +316,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); @@ -363,7 +364,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; } @@ -375,14 +379,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 */ @@ -459,11 +467,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 5ddee0fb..ffbad916 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -100,15 +100,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 { @@ -136,13 +139,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 { @@ -153,6 +157,7 @@ export class Member { this.connection = undefined; }); connection.logItem.finish(); + return disconnectLogItem; } /** @internal */ @@ -184,7 +189,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); + } } }); } From d69b1dc3e24f8f8ef9c1f60a45c45c67333be74c Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 6 May 2022 17:06:56 +0200 Subject: [PATCH 03/14] expose log items for exposing debugging info in sdk users --- src/matrix/calls/group/GroupCall.ts | 8 ++++++++ src/matrix/calls/group/Member.ts | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 2747d960..af3966a0 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -118,6 +118,14 @@ export class GroupCall extends EventEmitter<{change: never}> { return this.callContent?.["m.intent"]; } + /** + * 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; diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts index ffbad916..69e1eeea 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -70,6 +70,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; } From 814cee214c581fe110122e460806441d7487b0dc Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 6 May 2022 17:23:07 +0200 Subject: [PATCH 04/14] rename asJSON to toJSON --- src/logging/IDBLogPersister.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/logging/IDBLogPersister.ts b/src/logging/IDBLogPersister.ts index 2199b1ff..87996519 100644 --- a/src/logging/IDBLogPersister.ts +++ b/src/logging/IDBLogPersister.ts @@ -237,13 +237,13 @@ export class IDBLogExport { } asBlob(): BlobHandle { - const json = this.asJSON(); + const json = this.toJSON(); const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); return blob; } - asJSON(): string { + toJSON(): string { const log = { formatVersion: 1, appVersion: this._platform.updateService?.version, From 8140e4f2c373fa2c76d68a11e13c03284761d266 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:23:57 +0200 Subject: [PATCH 05/14] fix typescript errors --- src/logging/NullLogger.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/logging/NullLogger.ts b/src/logging/NullLogger.ts index 9e9b576b..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, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types"; +import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types"; function noop (): void {} @@ -25,6 +25,10 @@ export class NullLogger implements ILogger { addReporter() {} + get reporters(): ReadonlyArray { + return []; + } + getOpenRootItems(): Iterable { return []; } @@ -58,13 +62,13 @@ export class NullLogger implements ILogger { } 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; } From cd8fac2872fff8abb870059e6f1134c2fa8911b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:31:19 +0200 Subject: [PATCH 06/14] update TODO --- src/matrix/calls/TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a2a17dbf7af7fd0c7d5e0219676720c97f55e82a Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:50:52 +0200 Subject: [PATCH 07/14] fix unit test --- src/platform/web/Platform.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 19eef367..846f7b1c 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -323,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); } }; From 2a729f8969b496e3c19d73288af09f82d5660dc9 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 11:02:15 +0200 Subject: [PATCH 08/14] support loading logs through postMessage in logviewer --- scripts/logviewer/index.html | 15 +++++++++++++++ scripts/logviewer/main.js | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/logviewer/index.html b/scripts/logviewer/index.html index 08ba2f3c..109cf8d1 100644 --- a/scripts/logviewer/index.html +++ b/scripts/logviewer/index.html @@ -218,5 +218,20 @@
+ diff --git a/scripts/logviewer/main.js b/scripts/logviewer/main.js index 3ae860b2..e552a094 100644 --- a/scripts/logviewer/main.js +++ b/scripts/logviewer/main.js @@ -164,7 +164,11 @@ function getRootItemHeader(prevItem, item) { async function loadFile() { const file = await openFile(); document.getElementById("filename").innerText = file.name; - const json = await readFileAsText(file); + await loadBlob(file); +} + +export async function loadBlob(blob) { + const json = await readFileAsText(blob); const logs = JSON.parse(json); logs.items.sort((a, b) => itemStart(a) - itemStart(b)); rootItem = {c: logs.items}; @@ -181,6 +185,7 @@ async function loadFile() { return fragment; }, document.createDocumentFragment()); main.replaceChildren(fragment); + main.scrollTop = main.scrollHeight; } // TODO: make this use processRecursively From d85f93fb16aa505499e4d9b0bef0936edb4b2970 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 11:02:39 +0200 Subject: [PATCH 09/14] allow opening the logs straight in the log viewer from settings --- .../session/settings/SettingsViewModel.js | 7 ++++- .../web/ui/session/settings/SettingsView.js | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index e7990844..d0f2e91d 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -136,9 +136,14 @@ export class SettingsViewModel extends ViewModel { } async exportLogs() { + 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(); - this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); + return logExport.asBlob(); } async togglePushNotifications() { diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 93e44307..58992d9f 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,25 @@ export class SettingsView extends TemplateView { })]; } } + +async function openLogs(vm) { + const logviewerUrl = (await import("../../../../../../scripts/logviewer/index.html?url")).default; + const win = window.open(logviewerUrl); + await new Promise(async r => { + let receivedPong = false; + const waitForPong = event => { + if (event.data.type === "pong") { + window.removeEventListener("message", waitForPong); + receivedPong = true; + r(); + } + }; + window.addEventListener("message", waitForPong); + while (!receivedPong) { + win.postMessage({type: "ping"}); + await new Promise(rr => setTimeout(rr), 100); + } + }); + const logs = await vm.exportLogsBlob(); + win.postMessage({type: "open", logs: logs.nativeBlob}); +} From c823bb125fe21d90b42bbd3e8dd6f20608590d1f Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 11:20:25 +0200 Subject: [PATCH 10/14] fix lint error --- .../web/ui/session/settings/SettingsView.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 58992d9f..e07aaff5 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -145,20 +145,23 @@ export class SettingsView extends TemplateView { async function openLogs(vm) { const logviewerUrl = (await import("../../../../../../scripts/logviewer/index.html?url")).default; const win = window.open(logviewerUrl); - await new Promise(async r => { + await new Promise((resolve, reject) => { let receivedPong = false; const waitForPong = event => { if (event.data.type === "pong") { window.removeEventListener("message", waitForPong); receivedPong = true; - r(); + resolve(); + } + }; + const sendPings = async () => { + while (!receivedPong) { + win.postMessage({type: "ping"}); + await new Promise(rr => setTimeout(rr), 100); } }; window.addEventListener("message", waitForPong); - while (!receivedPong) { - win.postMessage({type: "ping"}); - await new Promise(rr => setTimeout(rr), 100); - } + sendPings().catch(reject); }); const logs = await vm.exportLogsBlob(); win.postMessage({type: "open", logs: logs.nativeBlob}); From 1d900b518453ebb838fdba7aca2ca51ed4216856 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 12:14:09 +0200 Subject: [PATCH 11/14] finish open window and poll code for logviewer --- .../web/ui/session/settings/SettingsView.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index e07aaff5..09d3cb5a 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -146,18 +146,24 @@ async function openLogs(vm) { const logviewerUrl = (await import("../../../../../../scripts/logviewer/index.html?url")).default; const win = window.open(logviewerUrl); await new Promise((resolve, reject) => { - let receivedPong = false; + let shouldSendPings = true; + const cleanup = () => { + shouldSendPings = false; + window.removeEventListener("message", waitForPong); + }; const waitForPong = event => { if (event.data.type === "pong") { - window.removeEventListener("message", waitForPong); - receivedPong = true; + cleanup(); resolve(); } }; const sendPings = async () => { - while (!receivedPong) { + while (shouldSendPings) { win.postMessage({type: "ping"}); - await new Promise(rr => setTimeout(rr), 100); + await new Promise(rr => setTimeout(rr, 50)); + if (win.closed) { + cleanup(); + } } }; window.addEventListener("message", waitForPong); From f6ea7803f2bc567039fac8c4b9bd6db8d801b318 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 18:03:15 +0200 Subject: [PATCH 12/14] move logviewer to own package --- package.json | 1 + scripts/logviewer/file.js | 51 --- scripts/logviewer/html.js | 110 ----- scripts/logviewer/index.html | 237 ---------- scripts/logviewer/main.js | 430 ------------------ .../web/ui/session/settings/SettingsView.js | 2 +- yarn.lock | 5 + 7 files changed, 7 insertions(+), 829 deletions(-) delete mode 100644 scripts/logviewer/file.js delete mode 100644 scripts/logviewer/html.js delete mode 100644 scripts/logviewer/index.html delete mode 100644 scripts/logviewer/main.js 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 109cf8d1..00000000 --- a/scripts/logviewer/index.html +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - - - -
- - - - - diff --git a/scripts/logviewer/main.js b/scripts/logviewer/main.js deleted file mode 100644 index e552a094..00000000 --- a/scripts/logviewer/main.js +++ /dev/null @@ -1,430 +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; - await loadBlob(file); -} - -export async function loadBlob(blob) { - const json = await readFileAsText(blob); - 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); - main.scrollTop = main.scrollHeight; -} - -// 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/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 09d3cb5a..ffc20f25 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -143,7 +143,7 @@ export class SettingsView extends TemplateView { } async function openLogs(vm) { - const logviewerUrl = (await import("../../../../../../scripts/logviewer/index.html?url")).default; + const logviewerUrl = (await import("@matrixdotorg/structured-logviewer/index.html?url")).default; const win = window.open(logviewerUrl); await new Promise((resolve, reject) => { let shouldSendPings = true; 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" From e2621015e179c2d00ef7129a6c2384be3902b189 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Tue, 10 May 2022 20:08:58 +0200 Subject: [PATCH 13/14] don't include log viewer in production build --- src/platform/web/ui/session/settings/SettingsView.js | 7 +++++-- vite.config.js | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index ffc20f25..f375a4be 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -142,9 +142,12 @@ export class SettingsView extends TemplateView { } } + async function openLogs(vm) { - const logviewerUrl = (await import("@matrixdotorg/structured-logviewer/index.html?url")).default; - const win = window.open(logviewerUrl); + // 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 = () => { 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), }); }); From 21065791a86d53fda3c478bf9853646670338dad Mon Sep 17 00:00:00 2001 From: Robert Long Date: Tue, 10 May 2022 16:58:03 -0700 Subject: [PATCH 14/14] Fix removing members in handleCallMemberEvent --- src/matrix/calls/CallHandler.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 7fbe6103..92f7314c 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"] ?? []; for (const call of calls) { const callId = call["m.call_id"]; @@ -204,7 +209,8 @@ export class CallHandler { groupCall?.updateMembership(userId, call, 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) { @@ -215,9 +221,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); } } }