Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
11 changes: 9 additions & 2 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {EventEmitter} from 'events';
import {zlibSync, strToU8} from 'fflate';
import {MediaDevices, CallsClientConfig, CallsClientStats, TrackMetadata} from 'src/types/types';

import {logDebug, logErr, logInfo, logWarn, persistClientLogs} from './log';
import {logDebug, logErr, logInfo, logWarn, persistClientLogs, flushLogsToAccumulated} from './log';
import {getScreenStream, getPersistentStorage} from './utils';
import {WebSocketClient, WebSocketError, WebSocketErrorType} from './websocket';
import {
Expand Down Expand Up @@ -837,9 +837,16 @@ export default class CallsClient extends EventEmitter {
this.closed = true;
if (this.peer) {
this.getStats().then((stats) => {
// Flush logs with stats to accumulated buffer
flushLogsToAccumulated(stats);

// Also save to stats storage for backwards compatibility
getPersistentStorage().setItem(STORAGE_CALLS_CLIENT_STATS_KEY, JSON.stringify(stats));
}).catch((statsErr) => {
logErr(statsErr);

// Still flush logs even if stats failed
flushLogsToAccumulated();
});
this.peer.destroy();
this.peer = null;
Expand Down Expand Up @@ -1227,7 +1234,7 @@ export default class CallsClient extends EventEmitter {

return {
initTime: this.initTime,
callID: this.channelID,
channelID: this.channelID,
tracksInfo,
rtcStats: stats ? parseRTCStats(stats) : null,
};
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,7 @@ export const STORAGE_CALLS_DEFAULT_VIDEO_INPUT_KEY = 'calls_default_video_input'
export const STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY = 'calls_experimental_features';
export const STORAGE_CALLS_MIRROR_VIDEO_KEY = 'calls_mirror_video';
export const STORAGE_CALLS_BLUR_BACKGROUND_KEY = 'calls_blur_background';

// Log buffer size limits
export const MAX_ACCUMULATED_LOG_SIZE = 1024 * 1024; // 1 MB
export const MAX_INLINE_LOG_POST_SIZE = 200 * 1024; // 200 KB
15 changes: 14 additions & 1 deletion webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ import SwitchCallModal from './components/switch_call_modal';
import {
handleDesktopJoinedCall,
} from './desktop';
import {logDebug, logErr, logWarn} from './log';
import {flushLogsToAccumulated, logDebug, logErr, logInfo, logWarn, startPeriodicLogCleanup} from './log';
import {pluginId} from './manifest';
import reducer from './reducers';
import {
Expand Down Expand Up @@ -345,6 +345,9 @@ export default class Plugin {
const theme = getTheme(store.getState());
setCallsGlobalCSSVars(theme.sidebarBg);

// Start periodic cleanup of accumulated background logs
startPeriodicLogCleanup();

// Register root DOM element for Calls. This is where the widget will render.
if (!document.getElementById('calls')) {
const callsRoot = document.createElement('div');
Expand Down Expand Up @@ -663,6 +666,16 @@ export default class Plugin {
const connectCall = async (channelID: string, title?: string, rootId?: string) => {
const channel = getChannel(store.getState(), channelID);

// Flush any pending logs from previous call
flushLogsToAccumulated();

// Log separator for new call
const isStarting = !channelHasCall(store.getState(), channelID);
logInfo(`=== ${isStarting ? 'starting' : 'joining'} call at ${new Date().toISOString()}`);

// Flush separator immediately (ensures desktop clients capture it)
flushLogsToAccumulated();

// Desktop handler
const payload = {
callID: channelID,
Expand Down
304 changes: 304 additions & 0 deletions webapp/src/log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

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';

// Mock the manifest
jest.mock('./manifest', () => ({
pluginId: 'com.mattermost.calls',
}));

// Mock getPersistentStorage
const mockStorage = new Map<string, string>();
jest.mock('./utils', () => ({
getPersistentStorage: () => ({
getItem: (key: string) => mockStorage.get(key) || null,
setItem: (key: string, value: string) => mockStorage.set(key, value),
removeItem: (key: string) => mockStorage.delete(key),
}),
}));

describe('log', () => {
/* eslint-disable no-console */
const originalConsole = {
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
};

beforeAll(() => {
console.error = jest.fn();
console.warn = jest.fn();
console.info = jest.fn();
console.debug = jest.fn();
});

afterAll(() => {
console.error = originalConsole.error;
console.warn = originalConsole.warn;
console.info = originalConsole.info;
console.debug = originalConsole.debug;
});
/* eslint-enable no-console */

beforeEach(() => {
flushLogsToAccumulated();
mockStorage.clear();
jest.clearAllMocks();
});

describe('flushLogsToAccumulated', () => {
test('should append in-memory logs to accumulated buffer', () => {
logDebug('test message 1');
logInfo('test message 2');

flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('test message 1');
expect(accumulated).toContain('test message 2');
expect(accumulated).toContain('debug');
expect(accumulated).toContain('info');
});

test('should include timestamp in log entries', () => {
const beforeTime = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
logDebug('test message');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain(beforeTime);
});

test('should append stats when provided', () => {
const stats: CallsClientStats = {
initTime: 123456,
channelID: 'test-channel',
tracksInfo: [],
rtcStats: {
ssrcStats: {},
iceStats: {
'in-progress': [],
succeeded: [],
waiting: [],
},
},
};

flushLogsToAccumulated(stats);

const accumulated = getClientLogs();
expect(accumulated).toContain('--- Call Stats ---');
expect(accumulated).toContain(JSON.stringify(stats));
expect(accumulated).toContain('---');
});

test('should handle empty logs gracefully', () => {
// Don't log anything, just flush
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toBe('');
});

test('should clear in-memory logs after flush', () => {
logDebug('test message');
flushLogsToAccumulated();

const firstAccumulated = getClientLogs();
expect(firstAccumulated).toContain('test message');

// Flush again without logging - should not duplicate
flushLogsToAccumulated();

const secondAccumulated = getClientLogs();
expect(secondAccumulated).toBe(firstAccumulated);
});

test('should accumulate logs across multiple flushes', () => {
logDebug('message 1');
flushLogsToAccumulated();

logDebug('message 2');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('message 1');
expect(accumulated).toContain('message 2');
});

test('should truncate when exceeding MAX_ACCUMULATED_LOG_SIZE', () => {
// Create a large log that definitely exceeds the 1MB limit
// Use a clearly identifiable pattern at start and end
const oldLogsStart = 'START_MARKER_SHOULD_BE_REMOVED\n';
const oldLogsMiddle = 'x'.repeat(MAX_ACCUMULATED_LOG_SIZE);
const oldLogsEnd = '\nEND_MARKER_SHOULD_BE_KEPT\n';
const largeMessage = oldLogsStart + oldLogsMiddle + oldLogsEnd;

mockStorage.set(STORAGE_CALLS_CLIENT_LOGS_KEY, largeMessage);

logDebug('newest message');
flushLogsToAccumulated();

const accumulated = getClientLogs();

// Should be at or under the limit
expect(accumulated.length).toBeLessThanOrEqual(MAX_ACCUMULATED_LOG_SIZE);

// Should contain truncation marker
expect(accumulated).toContain('[... older logs truncated ...]');

// Should contain new message (most recent)
expect(accumulated).toContain('newest message');

// Should NOT contain the start marker (old logs were truncated from the beginning)
expect(accumulated).not.toContain('START_MARKER_SHOULD_BE_REMOVED');

// Should contain the end marker (keeps most recent logs)
expect(accumulated).toContain('END_MARKER_SHOULD_BE_KEPT');
});

test('should keep most recent logs when truncating', () => {
// Fill storage with old logs
const oldLogs = 'old log line\n'.repeat(100000); // Very large
mockStorage.set(STORAGE_CALLS_CLIENT_LOGS_KEY, oldLogs);

logDebug('newest message');
flushLogsToAccumulated();

const accumulated = getClientLogs();

// Should contain the newest message
expect(accumulated).toContain('newest message');

// Should be properly sized
expect(accumulated.length).toBeLessThanOrEqual(MAX_ACCUMULATED_LOG_SIZE);
});

test('should handle truncation edge case: exactly at limit', () => {
const exactSizeLog = 'y'.repeat(MAX_ACCUMULATED_LOG_SIZE - 100);
mockStorage.set(STORAGE_CALLS_CLIENT_LOGS_KEY, exactSizeLog);

logDebug('new message');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated.length).toBeLessThanOrEqual(MAX_ACCUMULATED_LOG_SIZE);
});

test('should handle truncation with stats', () => {
// Fill storage near the limit
const largeLogs = 'log line\n'.repeat(MAX_ACCUMULATED_LOG_SIZE / 10);
mockStorage.set(STORAGE_CALLS_CLIENT_LOGS_KEY, largeLogs);

const stats: CallsClientStats = {
initTime: 123456,
channelID: 'test-channel',
tracksInfo: [],
rtcStats: {
ssrcStats: {},
iceStats: {
'in-progress': [],
succeeded: [],
waiting: [],
},
},
};

flushLogsToAccumulated(stats);

const accumulated = getClientLogs();

// Stats should be present
expect(accumulated).toContain('--- Call Stats ---');

// Should not exceed limit
expect(accumulated.length).toBeLessThanOrEqual(MAX_ACCUMULATED_LOG_SIZE);
});
});

describe('logging functions', () => {
test('logErr should append error logs', () => {
logErr('error message', 'additional', 'args');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('error');
expect(accumulated).toContain('error message');
expect(accumulated).toContain('additional');
expect(accumulated).toContain('args');
});

test('logWarn should append warning logs', () => {
logWarn('warning message');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('warn');
expect(accumulated).toContain('warning message');
});

test('logInfo should append info logs', () => {
logInfo('info message');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('info');
expect(accumulated).toContain('info message');
});

test('logDebug should append debug logs', () => {
logDebug('debug message');
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('debug');
expect(accumulated).toContain('debug message');
});

test('should handle multiple log levels in order', () => {
logErr('first');
logWarn('second');
logInfo('third');
logDebug('fourth');
flushLogsToAccumulated();

const accumulated = getClientLogs();
const firstPos = accumulated.indexOf('first');
const secondPos = accumulated.indexOf('second');
const thirdPos = accumulated.indexOf('third');
const fourthPos = accumulated.indexOf('fourth');

expect(firstPos).toBeLessThan(secondPos);
expect(secondPos).toBeLessThan(thirdPos);
expect(thirdPos).toBeLessThan(fourthPos);
});

test('should handle objects in log messages', () => {
const obj = {foo: 'bar', nested: {value: 123}};
logDebug('object:', obj);
flushLogsToAccumulated();

const accumulated = getClientLogs();
expect(accumulated).toContain('object:');
expect(accumulated).toContain('[object Object]');
Copy link
Member

Choose a reason for hiding this comment

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

just curious if this is the best we can do for logging something useful for objects? totally fine if it is, but thought I'd ask.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing that out, those generic "object" logs are not helpful.

});
});

describe('getClientLogs', () => {
test('should return empty string when no logs', () => {
const logs = getClientLogs();
expect(logs).toBe('');
});

test('should return accumulated logs', () => {
mockStorage.set(STORAGE_CALLS_CLIENT_LOGS_KEY, 'test logs');
const logs = getClientLogs();
expect(logs).toBe('test logs');
});
});
});
Loading
Loading