Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-43660] Expose some client-side metrics through /call stats command #57

Merged
merged 1 commit into from
May 4, 2022
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
41 changes: 41 additions & 0 deletions server/slash_command.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"strings"

Expand All @@ -14,6 +16,7 @@ const (
leaveCommandTrigger = "leave"
linkCommandTrigger = "link"
experimentalCommandTrigger = "experimental"
statsCommandTrigger = "stats"
)

var subCommands = []string{joinCommandTrigger, leaveCommandTrigger, linkCommandTrigger, experimentalCommandTrigger}
Expand All @@ -24,6 +27,7 @@ func getAutocompleteData() *model.AutocompleteData {
data.AddCommand(model.NewAutocompleteData(joinCommandTrigger, "", "Joins or starts a call in the current channel"))
data.AddCommand(model.NewAutocompleteData(leaveCommandTrigger, "", "Leaves a call in the current channel"))
data.AddCommand(model.NewAutocompleteData(linkCommandTrigger, "", "Generates a link to join a call in the current channel"))
data.AddCommand(model.NewAutocompleteData(statsCommandTrigger, "", "Shows some client-generated statistics about the call"))

experimentalCmdData := model.NewAutocompleteData(experimentalCommandTrigger, "", "Turns on/off experimental features")
experimentalCmdData.AddTextArgument("Available options: on, off", "", "on|off")
Expand Down Expand Up @@ -91,6 +95,32 @@ func handleExperimentalCommand(fields []string) (*model.CommandResponse, error)
}, nil
}

func handleStatsCommand(fields []string) (*model.CommandResponse, error) {
if len(fields) != 3 {
return nil, fmt.Errorf("Invalid number of arguments provided")
}

if len(fields[2]) < 2 {
return nil, fmt.Errorf("Invalid stats object")
}

js := fields[2][1 : len(fields[2])-1]

if js == "{}" {
return nil, fmt.Errorf("Empty stats object")
}

var buf bytes.Buffer
if err := json.Indent(&buf, []byte(js), "", " "); err != nil {
return nil, fmt.Errorf("Failed to indent JSON: %w", err)
}

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

func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
fields := strings.Fields(args.Command)

Expand Down Expand Up @@ -133,6 +163,17 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
return resp, nil
}

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

for _, cmd := range subCommands {
if cmd == subCmd {
return &model.CommandResponse{}, nil
Expand Down
19 changes: 18 additions & 1 deletion webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import {deflate} from 'pako/lib/deflate.js';

import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';

import {getPluginWSConnectionURL, getScreenStream, getPluginPath, setSDPMaxVideoBW} from './utils';
import {RTCStats} from 'src/types/types';

import {getPluginWSConnectionURL, getScreenStream, getPluginPath, setSDPMaxVideoBW} from './utils';
import WebSocketClient from './websocket';
import VoiceActivityDetector from './vad';

import {parseRTCStats} from './rtc_stats';

export default class CallsClient extends EventEmitter {
private peer: SimplePeer.Instance | null;
private ws: WebSocketClient | null;
Expand Down Expand Up @@ -463,4 +466,18 @@ export default class CallsClient extends EventEmitter {
}
this.isHandRaised = false;
}

public async getStats(): Promise<RTCStats | null> {
// @ts-ignore
// eslint-disable-next-line no-underscore-dangle
if (!this.peer || !this.peer._pc) {
throw new Error('not connected');
}

// @ts-ignore
// eslint-disable-next-line no-underscore-dangle
const stats = await this.peer._pc.getStats(null);

return parseRTCStats(stats);
}
}
16 changes: 15 additions & 1 deletion webapp/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/commo
import {getChannel as getChannelAction} from 'mattermost-redux/actions/channels';
import {getProfilesByIds as getProfilesByIdsAction} from 'mattermost-redux/actions/users';

import {RTCStats} from 'src/types/types';

import {isVoiceEnabled, connectedChannelID, voiceConnectedUsers, voiceChannelCallStartAt} from './selectors';

import {pluginId} from './manifest';
Expand Down Expand Up @@ -250,7 +252,7 @@ export default class Plugin {
registry.registerGlobalComponent(SwitchCallModal);
registry.registerGlobalComponent(ScreenSourceModal);

registry.registerSlashCommandWillBePostedHook((message, args) => {
registry.registerSlashCommandWillBePostedHook(async (message, args) => {
const fullCmd = message.trim();
const fields = fullCmd.split(/\s+/);
if (fields.length < 2) {
Expand Down Expand Up @@ -292,6 +294,18 @@ export default class Plugin {
console.log('experimental features disabled');
window.localStorage.removeItem('calls_experimental_features');
}
break;
case 'stats':
if (!window.callsClient) {
return {error: {message: 'You are not connected to any call'}};
}
try {
const stats = await window.callsClient.getStats();
console.log(JSON.stringify(stats, null, 2));
return {message: `/call stats "${JSON.stringify(stats)}"`, args};
Copy link
Member

Choose a reason for hiding this comment

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

Heh, this is clever. I never would have thought to do this. Makes it simpler to display to the user. 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's horribly clever 🙈 . To be honest I wanted a way to display an ephemeral message directly from the client but to my surprise it doesn't look like possible. The solution is hacky but it gets the job done so why not.

} catch (err) {
return {error: {message: err}};
}
}

return {message, args};
Expand Down
58 changes: 58 additions & 0 deletions webapp/src/rtc_stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {RTCStats} from 'src/types/types';

export function parseRTCStats(reports: RTCStatsReport): RTCStats {
const stats: RTCStats = {};
reports.forEach((report) => {
if (!report.ssrc) {
return;
}

if (!stats[report.ssrc]) {
stats[report.ssrc] = {
local: {},
remote: {},
};
}

switch (report.type) {
case 'inbound-rtp':
stats[report.ssrc].local.in = {
kind: report.kind,
packetsReceived: report.packetsReceived,
bytesReceived: report.bytesReceived,
packetsLost: report.packetsLost,
packetsDiscarded: report.packetsDiscarded,
jitter: report.jitter,
jitterBufferDelay: report.jitterBufferDelay,
};
break;
case 'outbound-rtp':
stats[report.ssrc].local.out = {
kind: report.kind,
packetsSent: report.packetsSent,
bytesSent: report.bytesSent,
retransmittedPacketsSent: report.retransmittedPacketsSent,
retransmittedBytesSent: report.retransmittedBytesSent,
nackCount: report.nackCount,
targetBitrate: report.targetBitrate,
};
break;
case 'remote-inbound-rtp':
stats[report.ssrc].remote.in = {
kind: report.kind,
packetsLost: report.packetsLost,
fractionLost: report.fractionLost,
jitter: report.jitter,
};
break;
case 'remote-outbound-rtp':
stats[report.ssrc].remote.out = {
kind: report.kind,
packetsSent: report.packetsSent,
bytesSent: report.bytesSent,
};
break;
}
});
return stats;
}
51 changes: 51 additions & 0 deletions webapp/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,54 @@ export type UserState = {
unmuted: boolean;
raised_hand: number;
}

export type RTCStats = {
[key: number]: {
local: RTCLocalStats,
remote: RTCRemoteStats,
}
}

export type RTCLocalStats = {
in?: RTCLocalInboundStats,
out?: RTCLocalOutboundStats,
}

export type RTCRemoteStats = {
in?: RTCRemoteInboundStats,
out?: RTCRemoteOutboundStats,
}

export type RTCLocalInboundStats = {
kind: string,
packetsReceived: number,
bytesReceived: number,
packetsLost: number,
packetsDiscarded: number,
jitter: number,
jitterBufferDelay: number,
}

export type RTCLocalOutboundStats = {
kind: string,
packetsSent: number,
bytesSent: number,
retransmittedPacketsSent: number,
retransmittedBytesSent: number,
nackCount: number,
targetBitrate: number,
}

export type RTCRemoteInboundStats = {
kind: string,
packetsLost: number,
fractionLost: number,
jitter: number,
}

export type RTCRemoteOutboundStats = {
kind: string,
packetsSent: number,
bytesSent: number,
}