Skip to content
Open
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
1 change: 1 addition & 0 deletions webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,7 @@ declare global {
registerPlugin(id: string, plugin: Plugin): void,

callsClient?: CallsClient,

webkitAudioContext: AudioContext,
basename: string,

Expand Down
151 changes: 150 additions & 1 deletion webapp/src/log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import {MAX_ACCUMULATED_LOG_SIZE, STORAGE_CALLS_CLIENT_LOGS_KEY} from 'src/constants';
import type {CallsClientStats} from 'src/types/types';

import {flushLogsToAccumulated, getClientLogs, logDebug, logErr, logInfo, logWarn} from './log';
import {flushAndGetLogs, flushLogsToAccumulated, getClientLogs, logDebug, logErr, logInfo, logWarn} from './log';

// Mock the manifest
jest.mock('./manifest', () => ({
Expand Down Expand Up @@ -301,4 +301,153 @@ describe('log', () => {
expect(logs).toBe('test logs');
});
});

describe('flushAndGetLogs', () => {
test('flushes in-memory buffer and returns accumulated logs', () => {
logInfo('hello from flushAndGetLogs');

const result = flushAndGetLogs();

expect(result).toContain('hello from flushAndGetLogs');

// In-memory is now clear; a second call returns the same storage content.
expect(flushAndGetLogs()).toBe(result);
});

test('returns empty string when no logs have been written', () => {
expect(flushAndGetLogs()).toBe('');
});

test('is exposed as window.callsClientFlushAndGetLogs', () => {
expect(typeof window.callsClientFlushAndGetLogs).toBe('function');

logInfo('via window');
const result = window.callsClientFlushAndGetLogs!();
expect(result).toContain('via window');
});
});

describe('in-memory auto-flush at 50 KB', () => {
test('flushes to storage without an explicit call when the buffer exceeds 50 KB', () => {
// A log line is the message plus a ~36-char timestamp prefix.
// Logging 51 KB of data pushes the buffer over the threshold.
const bigMessage = 'x'.repeat(51 * 1024);
logDebug(bigMessage);

// maybeFlush() ran automatically — storage should already contain the data.
expect(getClientLogs()).toContain(bigMessage.slice(0, 100));
});

test('small writes do not flush prematurely', () => {
logDebug('small');

// Buffer is well under 50 KB — storage should still be empty.
expect(getClientLogs()).toBe('');
});
});

describe('popout write-through', () => {
const originalOpener = Object.getOwnPropertyDescriptor(window, 'opener');

const setOpener = (opener: unknown) => {
Object.defineProperty(window, 'opener', {value: opener, configurable: true, writable: true});
};

afterEach(() => {
if (originalOpener) {
Object.defineProperty(window, 'opener', originalOpener);
} else {
setOpener(null);
}
});

test('routes logs to opener buffer when running as a popout', () => {
const append = jest.fn();
setOpener({callsClientLogAppend: append});

logInfo('popout gesture');
flushLogsToAccumulated();

// The formatted line went to the opener, not this realm's buffer.
expect(append).toHaveBeenCalledTimes(1);
expect(append.mock.calls[0][0]).toContain('popout gesture');
expect(append.mock.calls[0][0]).toContain('info');
expect(getClientLogs()).not.toContain('popout gesture');
});

test('falls back to local buffer when opener has no appender', () => {
setOpener({});

logInfo('no appender');
flushLogsToAccumulated();

expect(getClientLogs()).toContain('no appender');
});

test('falls back to local buffer when opener access throws (cross-origin)', () => {
setOpener(new Proxy({}, {
get() {
throw new Error('Blocked a frame with origin');
},
}));

logInfo('cross origin opener');
flushLogsToAccumulated();

expect(getClientLogs()).toContain('cross origin opener');
});

test('does not route to itself when there is no opener', () => {
setOpener(null);

logInfo('main window');
flushLogsToAccumulated();

expect(getClientLogs()).toContain('main window');
});
});

describe('window error listeners', () => {
test('uncaught error event is captured in the log buffer', () => {
window.dispatchEvent(new ErrorEvent('error', {
message: 'test uncaught error',
filename: 'app.js',
lineno: 10,
colno: 5,
error: new Error('test uncaught error'),
}));
flushLogsToAccumulated();

const logs = getClientLogs();
expect(logs).toContain('[uncaught]');
expect(logs).toContain('test uncaught error');
expect(logs).toContain('app.js:10:5');
});

test('unhandledrejection event is captured in the log buffer', () => {
// PromiseRejectionEvent is not available in JSDOM, so synthesize an
// event with the same shape our handler reads (event.reason).
const reason = new Error('rejected promise');
const event = new Event('unhandledrejection') as PromiseRejectionEvent;
Object.defineProperty(event, 'reason', {value: reason});
window.dispatchEvent(event);
flushLogsToAccumulated();

const logs = getClientLogs();
expect(logs).toContain('[unhandledrejection]');
expect(logs).toContain('rejected promise');
});

test('unhandledrejection with non-Error reason uses formatArg (not [object Object])', () => {
const event = new Event('unhandledrejection') as PromiseRejectionEvent;
Object.defineProperty(event, 'reason', {value: {code: 42, msg: 'oops'}});
window.dispatchEvent(event);
flushLogsToAccumulated();

const logs = getClientLogs();
expect(logs).toContain('[unhandledrejection]');
expect(logs).toContain('"code":42');
expect(logs).not.toContain('[object Object]');
});
});
});
85 changes: 84 additions & 1 deletion webapp/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,34 @@ import {getPersistentStorage} from 'src/utils';

import {pluginId} from './manifest';

declare global {
interface Window {
callsClientLogAppend?: (line: string) => void;
callsClientFlushAndGetLogs?: () => string;
}
}

let clientLogs = '';

const maxArgLength = 256;

// Flush the in-memory buffer to storage once it exceeds this size. Keeps
// memory bounded between calls and during plugin-inactive periods when the
// window error/unhandledrejection listeners are still writing to the buffer.
// String .length is O(1) in JS so this check is cheap on every write.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this comment about .length feels out of place: we're documenting the const, not the code that later uses it. Maybe move (or remove, if trivial)?

const maxInMemoryLogSize = 50 * 1024;

function maybeFlush() {
if (clientLogs.length > maxInMemoryLogSize) {
try {
flushLogsToAccumulated();
} catch {
// Storage quota or security error — keep only the most recent portion in memory.
clientLogs = clientLogs.slice(-maxInMemoryLogSize);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function formatArg(a: unknown): string {
if (a instanceof Error) {
return a.message;
Expand All @@ -28,8 +52,59 @@ function formatArg(a: unknown): string {
return String(a);
}

// Appends a fully-formatted log line to this realm's in-memory buffer. Exposed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we talk about exposing it, but that's "how it's used", not what this does.

// on `window` so the expanded-view popout can write through to its opener's
// buffer rather than persisting separately.
function appendLogLine(line: string) {
clientLogs += line;
maybeFlush();
}

function appendClientLog(level: string, ...args: unknown[]) {
clientLogs += `${level} [${new Date().toISOString()}] ${args.map(formatArg).join(' ')}\n`;
// Serialize in the originating realm: Error/object args belong to this
// window's realm and would fail instanceof checks if passed to the opener.
const line = `${level} [${new Date().toISOString()}] ${args.map(formatArg).join(' ')}\n`;

// In the expanded-view popout, route the line to the opener's buffer so
// popout-realm logs ride the main window's existing flush machinery (single
// source of truth, no cross-window storage read-modify-write race).
try {
const opener = window.opener as Window | null;
if (opener && opener !== window && typeof opener.callsClientLogAppend === 'function') {
opener.callsClientLogAppend(line);
return;
}
} catch {
// Cross-origin opener: fall through to this realm's local buffer.
}

clientLogs += line;
maybeFlush();
}

// Expose this realm's appender and flush+getter so an expanded-view popout can
// write logs through to (and read them back from) the opener's realm.
if (typeof window !== 'undefined') {
window.callsClientLogAppend = appendLogLine;
window.callsClientFlushAndGetLogs = flushAndGetLogs;
Comment on lines +88 to +89

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of "polluting" window directly, do we have the option of extending an existing calls global (i.e. window.callsClient.*)?

More salient, the asymmetry between LogAppend and appendLogLine makes this harder to understand and grep: can we converge?


// Wire uncaught JS errors and unhandled promise rejections into the client-
// log buffer. Without this, exceptions that crash a handler go only to
// console.error and never appear in /call logs uploads.
window.addEventListener('error', (event: ErrorEvent) => {
const {message, filename, lineno, colno, error} = event;
const errStr = error instanceof Error ?
(error.stack || `${error.name}: ${error.message}`) :
String(error || message);
appendClientLog('error', `[uncaught] ${errStr} (${filename}:${lineno}:${colno})`);
});

window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const reason = event.reason instanceof Error ?
(event.reason.stack || `${event.reason.name}: ${event.reason.message}`) :
formatArg(event.reason);
appendClientLog('error', `[unhandledrejection] ${reason}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

export function flushLogsToAccumulated(stats?: CallsClientStats | null) {
Expand Down Expand Up @@ -74,6 +149,14 @@ export function getClientLogs() {
return getPersistentStorage().getItem(STORAGE_CALLS_CLIENT_LOGS_KEY) || '';
}

// Flushes this realm's in-memory buffer to storage and returns the full
// accumulated log string. Exposed on `window` so a popout can delegate the
// entire flush+read to its opener's realm in one call.
export function flushAndGetLogs(): string {
flushLogsToAccumulated();
return getClientLogs();
}

export function logErr(...args: unknown[]) {
console.error(`${pluginId}:`, ...args);
try {
Expand Down
22 changes: 19 additions & 3 deletions webapp/src/slash_commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import RestClient from 'src/rest_client';
import {modals} from 'src/webapp_globals';

import {flushLogsToAccumulated, getClientLogs, logDebug} from './log';
import {flushAndGetLogs, logDebug} from './log';
import {
areGroupCallsAllowed,
channelHasCall,
Expand Down Expand Up @@ -177,8 +177,24 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC
return {message: `/call stats ${btoa(data)}`, args};
}
case 'logs': {
flushLogsToAccumulated();
const allLogs = getClientLogs();
// When running in the expanded-view popout, delegate flush+read to the
// opener's realm. The popout's own in-memory buffer is ~empty (every
// appended line is forwarded to opener.callsClientLogAppend), and on
// web sessionStorage is per-window so a local read would miss the
// opener's accumulated logs entirely.
let allLogs: string;
try {
const opener = window.opener as Window | null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels backward to have every client of these methods have to know about window.opener. Could we flip the interface to instead have a common method we call, but that itself resolves the window.opener? This would help focus the code in the caller to the relevant parts (flush, get logs) instead of having to sprinkle awareness of the opener distinction everytime we need to use these methods.

if (opener && opener !== window && typeof opener.callsClientFlushAndGetLogs === 'function') {
allLogs = opener.callsClientFlushAndGetLogs();
} else {
allLogs = flushAndGetLogs();
}
} catch {
// Cross-origin opener (SecurityError) or missing function — fall
// back to the local realm.
allLogs = flushAndGetLogs();
}

if (!allLogs || allLogs.trim().length === 0) {
return {error: {message: 'No call logs available'}};
Expand Down
Loading