Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/webview/js/InboundEventLogger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/* @flow strict-local */
import type {
WebViewUpdateEvent as WebViewInboundEvent,
WebViewUpdateEventContent as WebViewInboundEventContent,
WebViewUpdateEventFetching as WebViewInboundEventFetching,
WebViewUpdateEventTyping as WebViewInboundEventTyping,
WebViewUpdateEventReady as WebViewInboundEventReady,
WebViewUpdateEventMessagesRead as WebViewInboundEventMessagesRead,
} from '../webViewHandleUpdates';
import type { JSONable } from '../../utils/jsonable';
import sendMessage from './sendMessage';
import { ensureUnreachable } from '../../types';

// TODO: Make exact (see note in jsonable.js).
type Scrub<E: WebViewInboundEvent> = { [key: $Keys<E>]: JSONable };

type ScrubbedInboundEvent =
| Scrub<WebViewInboundEventContent>
| Scrub<WebViewInboundEventFetching>
| Scrub<WebViewInboundEventTyping>
| Scrub<WebViewInboundEventReady>
| Scrub<WebViewInboundEventMessagesRead>;

type ScrubbedInboundEventItem = {|
timestamp: number,
type: 'inbound',
scrubbedEvent: ScrubbedInboundEvent,
|};

/**
* Grab interesting but not privacy-sensitive message-loading state.
*
* Takes the "content" from an inbound WebView event, an HTML string,
* and returns the opening div#message-loading tag, so we know whether
* it's visible.
*/
const placeholdersDivTagFromContent = (content: string): string | null => {
const match = new RegExp('<div id="message-loading" class="(?:hidden)?">').exec(content);
return match !== null ? match[0] : null;
};

export default class InboundEventLogger {
_captureStartTime: number | void;
_captureEndTime: number | void;
_isCapturing: boolean;
_capturedInboundEventItems: ScrubbedInboundEventItem[];

/**
* Minimally transform an inbound event to remove sensitive data.
*/
static scrubInboundEvent(event: WebViewInboundEvent): ScrubbedInboundEvent {
// Don't spread the event (e.g., `...event`); instead, rebuild it.
// That way, a new property, when added, won't automatically be
// sent to Sentry un-scrubbed.
switch (event.type) {
case 'content': {
return {
type: event.type,
scrollMessageId: event.scrollMessageId,
auth: 'redacted',
content: placeholdersDivTagFromContent(event.content),
updateStrategy: event.updateStrategy,
};
}
case 'fetching': {
return {
type: event.type,
showMessagePlaceholders: event.showMessagePlaceholders,
fetchingOlder: event.fetchingOlder,
fetchingNewer: event.fetchingNewer,
};
}
case 'typing': {
return {
type: event.type,
// Empty if no one is typing; otherwise, has avatar URLs.
content: event.content !== '',
};
}
case 'ready': {
return {
type: event.type,
};
}
case 'read': {
return {
type: event.type,
messageIds: event.messageIds,
};
}
default: {
ensureUnreachable(event);
return {
type: event.type,
};
}
}
}

constructor() {
this._isCapturing = false;
this._capturedInboundEventItems = [];
}

startCapturing() {
if (this._isCapturing) {
throw new Error('InboundEventLogger: Tried to call startCapturing while already capturing.');
} else if (this._capturedInboundEventItems.length > 0 || this._captureEndTime !== undefined) {
throw new Error('InboundEventLogger: Tried to call startCapturing before resetting.');
}
this._isCapturing = true;
this._captureStartTime = Date.now();
}

stopCapturing() {
if (!this._isCapturing) {
throw new Error('InboundEventLogger: Tried to call stopCapturing while not capturing.');
}
this._isCapturing = false;
this._captureEndTime = Date.now();
}

send() {
if (this._isCapturing) {
throw new Error('InboundEventLogger: Tried to send captured events while still capturing.');
}
sendMessage({
type: 'warn',
details: {
startTime: this._captureStartTime ?? null,
endTime: this._captureEndTime ?? null,
inboundEventItems: this._capturedInboundEventItems,
},
});
}

reset() {
this._captureStartTime = undefined;
this._captureEndTime = undefined;
this._capturedInboundEventItems = [];
this._isCapturing = false;
}

maybeCaptureInboundEvent(event: WebViewInboundEvent) {
if (this._isCapturing) {
const item = {
type: 'inbound',
timestamp: Date.now(),
// Scrubbing up front, rather than just before sending, means
// it might be a waste of work -- we may never send. But it's
// not a *ton* of work, and it's currently the case that
// scrubbed events are much lighter than unscrubbed ones
// (unscrubbed events can have very long `content` strings).
scrubbedEvent: InboundEventLogger.scrubInboundEvent(event),
};
this._capturedInboundEventItems.push(item);
}
}
}
Loading