Skip to content

Commit

Permalink
Make stats and client logs more easily accessible through slash commands
Browse files Browse the repository at this point in the history
  • Loading branch information
streamer45 committed Aug 7, 2024
1 parent 2ec2ecb commit da07424
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 16 deletions.
43 changes: 43 additions & 0 deletions e2e/tests/desktop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,47 @@ test.describe('desktop', () => {
// Verify error is getting sent
expect(desktopAPICalls.leaveCall).toBe(true);
});

test('desktop: /call stats command', async ({page}) => {
// start call in global widget
const devPage = new PlaywrightDevPage(page);
await devPage.openWidget(getChannelNamesForTest()[0]);
await devPage.leaveCall();

// Need to wait a moment since the the leave call happens in
// a setTimeout handler.
await devPage.wait(500);

// Go back to center channel view
await devPage.goto();

// Issue slash command
await devPage.sendSlashCommand('/call stats');
await devPage.wait(500);

// Veirfy call stats have been returned
await expect(page.locator('.post__body').last()).toContainText('"initTime"');
await expect(page.locator('.post__body').last()).toContainText('"callID"');
});

test('desktop: /call logs command', async ({page}) => {
// start call in global widget
const devPage = new PlaywrightDevPage(page);
await devPage.openWidget(getChannelNamesForTest()[0]);
await devPage.leaveCall();

// Need to wait a moment since the the leave call happens in
// a setTimeout handler.
await devPage.wait(500);

// Go back to center channel view
await devPage.goto();

// Issue slash command
await devPage.sendSlashCommand('/call logs');
await devPage.wait(500);

// Veirfy call logs have been returned
await expect(page.locator('.post__body').last()).toContainText('join ack received, initializing connection');
});
});
30 changes: 30 additions & 0 deletions server/slash_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
endCommandTrigger = "end"
recordingCommandTrigger = "recording"
hostCommandTrigger = "host"
logsCommandTrigger = "logs"
)

var subCommands = []string{
Expand All @@ -36,6 +37,7 @@ var subCommands = []string{
endCommandTrigger,
statsCommandTrigger,
recordingCommandTrigger,
logsCommandTrigger,
}

func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
Expand All @@ -49,6 +51,7 @@ func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
data.AddCommand(model.NewAutocompleteData(linkCommandTrigger, "", "Generate a link to join a call in the current channel."))
data.AddCommand(model.NewAutocompleteData(statsCommandTrigger, "", "Show client-generated statistics about the call."))
data.AddCommand(model.NewAutocompleteData(endCommandTrigger, "", "End the call for everyone. All the participants will drop immediately."))
data.AddCommand(model.NewAutocompleteData(logsCommandTrigger, "", "Show client logs."))

experimentalCmdData := model.NewAutocompleteData(experimentalCommandTrigger, "", "Turn experimental features on or off.")
experimentalCmdData.AddTextArgument("Available options: on, off", "", "on|off")
Expand Down Expand Up @@ -157,6 +160,22 @@ func handleStatsCommand(fields []string) (*model.CommandResponse, error) {
}, nil
}

func handleLogsCommand(fields []string) (*model.CommandResponse, error) {
if len(fields) < 3 {
return nil, fmt.Errorf("Empty logs")
}

logs, err := base64.StdEncoding.DecodeString(fields[2])
if err != nil {
return nil, fmt.Errorf("Failed to decode payload: %w", err)
}

return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("```\n%s\n```", logs),
}, nil
}

func (p *Plugin) handleEndCallCommand() (*model.CommandResponse, error) {
return &model.CommandResponse{}, nil
}
Expand Down Expand Up @@ -245,6 +264,17 @@ func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*mo
return resp, nil
}

if subCmd == logsCommandTrigger {
resp, err := handleLogsCommand(fields)
if err != nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: fmt.Sprintf("Error: %s", err.Error()),
}, nil
}
return resp, nil
}

if subCmd == endCommandTrigger {
resp, err := p.handleEndCallCommand()
if err != nil {
Expand Down
23 changes: 15 additions & 8 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import {EventEmitter} from 'events';
import {deflate} from 'pako/lib/deflate';
import {AudioDevices, CallsClientConfig, CallsClientStats, TrackInfo} from 'src/types/types';

import {logDebug, logErr, logInfo, logWarn} from './log';
import {logDebug, logErr, logInfo, logWarn, persistClientLogs} from './log';
import {getScreenStream} from './utils';
import {WebSocketClient, WebSocketError, WebSocketErrorType} from './websocket';
import {
STORAGE_CALLS_CLIENT_STATS_KEY,
STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY,
STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY,
} from 'src/constants';

export const AudioInputPermissionsError = new Error('missing audio input permissions');
export const AudioInputMissingError = new Error('no audio input available');
Expand Down Expand Up @@ -99,8 +104,8 @@ export default class CallsClient extends EventEmitter {
};
}

const defaultInputID = window.localStorage.getItem('calls_default_audio_input');
const defaultOutputID = window.localStorage.getItem('calls_default_audio_output');
const defaultInputID = window.localStorage.getItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY);
const defaultOutputID = window.localStorage.getItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY);
if (defaultInputID && !this.currentAudioInputDevice) {
const devices = this.audioDevices.inputs.filter((dev) => {
return dev.deviceId === defaultInputID;
Expand All @@ -114,7 +119,7 @@ export default class CallsClient extends EventEmitter {
this.currentAudioInputDevice = devices[0];
} else {
logDebug('audio input device not found');
window.localStorage.removeItem('calls_default_audio_input');
window.localStorage.removeItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY);
}
}

Expand All @@ -128,7 +133,7 @@ export default class CallsClient extends EventEmitter {
this.currentAudioOutputDevice = devices[0];
} else {
logDebug('audio output device not found');
window.localStorage.removeItem('calls_default_audio_output');
window.localStorage.removeItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY);
}
}

Expand Down Expand Up @@ -407,14 +412,15 @@ export default class CallsClient extends EventEmitter {
this.removeAllListeners('mos');
window.removeEventListener('beforeunload', this.onBeforeUnload);
navigator.mediaDevices?.removeEventListener('devicechange', this.onDeviceChange);
persistClientLogs();
}

public async setAudioInputDevice(device: MediaDeviceInfo) {
if (!this.peer) {
return;
}

window.localStorage.setItem('calls_default_audio_input', device.deviceId);
window.localStorage.setItem(STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY, device.deviceId);
this.currentAudioInputDevice = device;

// We emit this event so it's easier to keep state in sync between widget and pop out.
Expand Down Expand Up @@ -464,7 +470,7 @@ export default class CallsClient extends EventEmitter {
if (!this.peer) {
return;
}
window.localStorage.setItem('calls_default_audio_output', device.deviceId);
window.localStorage.setItem(STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY, device.deviceId);
this.currentAudioOutputDevice = device;

// We emit this event so it's easier to keep state in sync between widget and pop out.
Expand All @@ -484,7 +490,8 @@ export default class CallsClient extends EventEmitter {
this.closed = true;
if (this.peer) {
this.getStats().then((stats) => {
sessionStorage.setItem('calls_client_stats', JSON.stringify(stats));
const storage = window.desktop ? localStorage : sessionStorage;
storage.setItem(STORAGE_CALLS_CLIENT_STATS_KEY, JSON.stringify(stats));
}).catch((statsErr) => {
logErr(statsErr);
});
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,10 @@ export const CallTranscribingDisclaimerStrings: {[key: string]: {[key: string]:
export const DisabledCallsErr = new Error('Cannot start or join call: calls are disabled in this channel.');

export const supportedLocales = [];

// Local/Session storage keys
export const STORAGE_CALLS_CLIENT_STATS_KEY = 'calls_client_stats';
export const STORAGE_CALLS_CLIENT_LOGS_KEY = 'calls_client_logs';
export const STORAGE_CALLS_DEFAULT_AUDIO_INPUT_KEY = 'calls_default_audio_input';
export const STORAGE_CALLS_DEFAULT_AUDIO_OUTPUT_KEY = 'calls_default_audio_output';
export const STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY = 'calls_experimental_features';
38 changes: 36 additions & 2 deletions webapp/src/log.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,54 @@
/* eslint-disable no-console */

import {STORAGE_CALLS_CLIENT_LOGS_KEY} from 'src/constants';

import {pluginId} from './manifest';

let clientLogs = '';

function appendClientLog(level: string, ...args: unknown[]) {
clientLogs += `${level} [${new Date().toISOString()}] ${args}\n`;
}

export function persistClientLogs() {
const storage = window.desktop ? localStorage : sessionStorage;
storage.setItem(STORAGE_CALLS_CLIENT_LOGS_KEY, clientLogs);
clientLogs = '';
}

export function getClientLogs() {
const storage = window.desktop ? localStorage : sessionStorage;
return storage.getItem(STORAGE_CALLS_CLIENT_LOGS_KEY) || '';
}

export function logErr(...args: unknown[]) {
console.error(`${pluginId}:`, ...args);
try {
if (window.callsClient) {
appendClientLog('error', ...args);
}
} catch (err) {
console.error(err);
}
}

export function logWarn(...args: unknown[]) {
console.warn(`${pluginId}:`, ...args);
if (window.callsClient) {
appendClientLog('warn', ...args);
}
}

export function logInfo(...args: unknown[]) {
console.info(`${pluginId}:`, ...args);
if (window.callsClient) {
appendClientLog('info', ...args);
}
}

export function logDebug(...args: unknown[]) {
// TODO: convert to debug once we are out of beta.
console.info(`${pluginId}:`, ...args);
console.debug(`${pluginId}:`, ...args);
if (window.callsClient) {
appendClientLog('debug', ...args);
}
}
18 changes: 13 additions & 5 deletions webapp/src/slash_commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ import {
stopCallRecording,
trackEvent,
} from 'src/actions';
import {DisabledCallsErr} from 'src/constants';
import {
DisabledCallsErr,
STORAGE_CALLS_CLIENT_STATS_KEY,
STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY,
} from 'src/constants';
import * as Telemetry from 'src/types/telemetry';

import {logDebug} from './log';
import {getClientLogs, logDebug} from './log';
import {
channelHasCall,
channelIDForCurrentCall,
Expand Down Expand Up @@ -147,11 +151,11 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC
break;
}
if (fields[2] === 'on') {
window.localStorage.setItem('calls_experimental_features', 'on');
window.localStorage.setItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY, 'on');
logDebug('experimental features enabled');
} else if (fields[2] === 'off') {
logDebug('experimental features disabled');
window.localStorage.removeItem('calls_experimental_features');
window.localStorage.removeItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY);
}
break;
case 'stats': {
Expand All @@ -163,9 +167,13 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC
return {error: {message: err}};
}
}
const data = sessionStorage.getItem('calls_client_stats') || '{}';
const storage = window.desktop ? localStorage : sessionStorage;
const data = storage.getItem(STORAGE_CALLS_CLIENT_STATS_KEY) || '{}';
return {message: `/call stats ${btoa(data)}`, args};
}
case 'logs': {
return {message: `/call logs ${btoa(getClientLogs())}`, args};
}
case 'recording': {
if (fields.length < 3 || (fields[2] !== 'start' && fields[2] !== 'stop')) {
break;
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {IntlShape} from 'react-intl';
import {parseSemVer} from 'semver-parser';
import CallsClient from 'src/client';
import {STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY} from 'src/constants';
import RestClient from 'src/rest_client';
import {notificationSounds} from 'src/webapp_globals';

Expand Down Expand Up @@ -334,7 +335,7 @@ export function setSDPMaxVideoBW(sdp: string, bandwidth: number) {
}

export function hasExperimentalFlag() {
return window.localStorage.getItem('calls_experimental_features') === 'on';
return window.localStorage.getItem(STORAGE_CALLS_EXPERIMENTAL_FEATURES_KEY) === 'on';
}

export function split<T>(list: T[], i: number, pad = false): [list: T[], overflowed?: T[]] {
Expand Down

0 comments on commit da07424

Please sign in to comment.