Skip to content

Commit

Permalink
[MM-46807] Implement client-side telemetry (#185)
Browse files Browse the repository at this point in the history
* Implement client-side telemetry

* Initialize maps at runtime

* Send request only if diagnostics are enabled

* Instrument code

* Add user_close_expanded_view
  • Loading branch information
streamer45 authored Sep 12, 2022
1 parent e611c67 commit e672052
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 40 deletions.
21 changes: 10 additions & 11 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/pprof"
"regexp"
Expand All @@ -24,6 +23,8 @@ import (
var chRE = regexp.MustCompile(`^\/([a-z0-9]+)$`)
var callEndRE = regexp.MustCompile(`^\/calls\/([a-z0-9]+)\/end$`)

const requestBodyMaxSizeBytes = 1024 * 1024 // 1MB

type Call struct {
ID string `json:"id"`
StartAt int64 `json:"start_at"`
Expand All @@ -35,7 +36,7 @@ type Call struct {
}

type ChannelState struct {
ChannelID string `json:"channel_id"`
ChannelID string `json:"channel_id,omitempty"`
Enabled bool `json:"enabled"`
Call *Call `json:"call,omitempty"`
}
Expand Down Expand Up @@ -317,15 +318,8 @@ func (p *Plugin) handlePostChannel(w http.ResponseWriter, r *http.Request, chann
}
}

data, err := ioutil.ReadAll(r.Body)
if err != nil {
res.Err = err.Error()
res.Code = http.StatusInternalServerError
return
}

var info ChannelState
if err := json.Unmarshal(data, &info); err != nil {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, requestBodyMaxSizeBytes)).Decode(&info); err != nil {
res.Err = err.Error()
res.Code = http.StatusBadRequest
return
Expand Down Expand Up @@ -353,7 +347,7 @@ func (p *Plugin) handlePostChannel(w http.ResponseWriter, r *http.Request, chann

p.API.PublishWebSocketEvent(evType, nil, &model.WebsocketBroadcast{ChannelId: channelID, ReliableClusterSend: true})

if _, err := w.Write(data); err != nil {
if err := json.NewEncoder(w).Encode(info); err != nil {
p.LogError(err.Error())
}
}
Expand Down Expand Up @@ -533,6 +527,11 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
p.handleEndCall(w, r, matches[1])
return
}

if r.URL.Path == "/telemetry/track" {
p.handleTrackEvent(w, r)
return
}
}

http.NotFound(w, r)
Expand Down
89 changes: 89 additions & 0 deletions server/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,56 @@
package main

import (
"encoding/json"
"net/http"

"github.com/mattermost/mattermost-plugin-calls/server/telemetry"
)

const (
// server-side events
evCallStarted = "call_started"
evCallEnded = "call_ended"
evCallUserJoined = "call_user_joined"
evCallUserLeft = "call_user_left"
evCallNotifyAdmin = "call_notify_admin"
)

var (
telemetryClientTypes = []string{"web", "mobile", "desktop"}
telemetryClientEvents = []string{
"user_open_expanded_view",
"user_close_expanded_view",
"user_open_participants_list",
"user_close_participants_list",
"user_share_screen",
"user_unshare_screen",
"user_raise_hand",
"user_lower_hand",
"user_open_channel_link",
}
telemetryClientTypesMap map[string]struct{}
telemetryClientEventsMap map[string]struct{}
)

func init() {
telemetryClientEventsMap = make(map[string]struct{}, len(telemetryClientEvents))
for _, eventType := range telemetryClientEvents {
telemetryClientEventsMap[eventType] = struct{}{}
}
telemetryClientTypesMap = make(map[string]struct{}, len(telemetryClientTypes))
for _, clientType := range telemetryClientTypes {
telemetryClientTypesMap[clientType] = struct{}{}
}
}

type trackEventRequest struct {
Event string `json:"event"`
ClientType string `json:"clientType"`
Source string `json:"source"`
Props map[string]interface{} `json:"props"`
}

func (p *Plugin) track(ev string, props map[string]interface{}) {
p.mut.RLock()
defer p.mut.RUnlock()
Expand Down Expand Up @@ -68,3 +107,53 @@ func (p *Plugin) initTelemetry(enableDiagnostics *bool) error {
}
return nil
}

func (p *Plugin) handleTrackEvent(w http.ResponseWriter, r *http.Request) {
var res httpResponse
defer p.httpAudit("handleTrackEvent", &res, w, r)

p.mut.RLock()
telemetryEnabled := p.telemetry != nil
p.mut.RUnlock()

if !telemetryEnabled {
res.Err = "telemetry is disabled"
res.Code = http.StatusBadRequest
return
}

var data trackEventRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, requestBodyMaxSizeBytes)).Decode(&data); err != nil {
res.Err = err.Error()
res.Code = http.StatusBadRequest
return
}

if _, ok := telemetryClientEventsMap[data.Event]; !ok {
res.Err = "invalid telemetry event"
res.Code = http.StatusBadRequest
return
}

if _, ok := telemetryClientTypesMap[data.ClientType]; !ok {
res.Err = "invalid client type"
res.Code = http.StatusBadRequest
return
}

if data.Props == nil {
data.Props = map[string]interface{}{}
}

if data.Source != "" {
data.Props["Source"] = data.Source
}

data.Props["ActualUserID"] = r.Header.Get("Mattermost-User-Id")
data.Props["ClientType"] = data.ClientType

p.track(data.Event, data.Props)

res.Code = http.StatusOK
res.Msg = "success"
}
8 changes: 2 additions & 6 deletions webapp/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,8 @@
],
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": [
2,
{
"hoist": "functions"
}
],
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-tabs": 0,
Expand Down
24 changes: 24 additions & 0 deletions webapp/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {Client4} from 'mattermost-redux/client';
import {CloudCustomer} from '@mattermost/types/cloud';

import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';

import {CallsConfig} from 'src/types/types';
import * as Telemetry from 'src/types/telemetry';
import {getPluginPath} from 'src/utils';

import {modals, openPricingModal} from 'src/webapp_globals';
Expand Down Expand Up @@ -163,3 +165,25 @@ export const endCall = (channelID: string) => {
return axios.post(`${getPluginPath()}/calls/${channelID}/end`, null,
{headers: {'X-Requested-With': 'XMLHttpRequest'}});
};

export const trackEvent = (event: Telemetry.Event, source: Telemetry.Source, props?: Record<string, any>) => {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
const config = getConfig(getState());
if (config.DiagnosticsEnabled !== 'true') {
return;
}
if (!props) {
props = {};
}
const eventData = {
event,
clientType: window.desktop ? 'desktop' : 'web',
source,
props,
};
Client4.doFetch(
`${getPluginPath()}/telemetry/track`,
{method: 'post', body: JSON.stringify(eventData)},
);
};
};
37 changes: 26 additions & 11 deletions webapp/src/components/call_widget/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {changeOpacity} from 'mattermost-redux/utils/theme_utils';
import {isDirectChannel, isGroupChannel, isOpenChannel, isPrivateChannel} from 'mattermost-redux/utils/channel_utils';

import {UserState, AudioDevices} from 'src/types/types';
import * as Telemetry from 'src/types/telemetry';
import {
getUserDisplayName,
hasExperimentalFlag,
Expand Down Expand Up @@ -68,6 +69,7 @@ interface Props {
show: boolean,
showExpandedView: () => void,
showScreenSourceModal: () => void,
trackEvent: (event: Telemetry.Event, source: Telemetry.Source, props?: Record<string, any>) => void,
}

interface DraggingState {
Expand Down Expand Up @@ -256,13 +258,13 @@ export default class CallWidget extends React.PureComponent<Props, State> {
this.onMuteToggle();
break;
case RAISE_LOWER_HAND:
this.onRaiseHandToggle();
this.onRaiseHandToggle(true);
break;
case SHARE_UNSHARE_SCREEN:
this.onShareScreenToggle();
this.onShareScreenToggle(true);
break;
case PARTICIPANTS_LIST_TOGGLE:
this.onParticipantsButtonClick();
this.onParticipantsButtonClick(true);
break;
case LEAVE_CALL:
this.onDisconnectClick();
Expand Down Expand Up @@ -422,18 +424,21 @@ export default class CallWidget extends React.PureComponent<Props, State> {
this.setState({showMenu: false});
}

onShareScreenToggle = async () => {
onShareScreenToggle = async (fromShortcut?: boolean) => {
const state = {} as State;

if (this.props.screenSharingID === this.props.currentUserID) {
window.callsClient.unshareScreen();
state.screenStream = null;
this.props.trackEvent(Telemetry.Event.UnshareScreen, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'});
} else if (!this.props.screenSharingID) {
if (window.desktop && compareSemVer(window.desktop.version, '5.1.0') >= 0) {
this.props.showScreenSourceModal();
} else {
const stream = await window.callsClient.shareScreen('', hasExperimentalFlag());
state.screenStream = stream;
}
this.props.trackEvent(Telemetry.Event.ShareScreen, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'});
}

this.setState({
Expand Down Expand Up @@ -486,7 +491,10 @@ export default class CallWidget extends React.PureComponent<Props, State> {
});
}

onParticipantsButtonClick = () => {
onParticipantsButtonClick = (fromShortcut?: boolean) => {
const event = this.state.showParticipantsList ? Telemetry.Event.CloseParticipantsList : Telemetry.Event.OpenParticipantsList;
this.props.trackEvent(event, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'});

this.setState({
showParticipantsList: !this.state.showParticipantsList,
showMenu: false,
Expand Down Expand Up @@ -566,7 +574,7 @@ export default class CallWidget extends React.PureComponent<Props, State> {
borderRadius: '4px',
fontWeight: 600,
}}
onClick={this.onShareScreenToggle}
onClick={() => this.onShareScreenToggle()}
>
{'Stop sharing'}
</button>
Expand Down Expand Up @@ -651,7 +659,7 @@ export default class CallWidget extends React.PureComponent<Props, State> {
className={`style--none ${!sharingID || isSharing ? 'button-controls' : 'button-controls-disabled'} button-controls--wide`}
disabled={sharingID !== '' && !isSharing}
style={{background: isSharing ? 'rgba(var(--dnd-indicator-rgb), 0.12)' : ''}}
onClick={this.onShareScreenToggle}
onClick={() => this.onShareScreenToggle()}
>
<ScreenIcon
style={{
Expand Down Expand Up @@ -902,7 +910,7 @@ export default class CallWidget extends React.PureComponent<Props, State> {
color: sharingID !== '' && !isSharing ? changeOpacity(this.props.theme.centerChannelColor, 0.34) : '',
}}
disabled={Boolean(sharingID !== '' && !isSharing)}
onClick={this.onShareScreenToggle}
onClick={() => this.onShareScreenToggle()}
>
<ScreenIcon
style={{width: '16px', height: '16px', marginRight: '8px'}}
Expand Down Expand Up @@ -1114,6 +1122,8 @@ export default class CallWidget extends React.PureComponent<Props, State> {
return;
}

this.props.trackEvent(Telemetry.Event.OpenExpandedView, Telemetry.Source.Widget, {initiator: 'button'});

// TODO: remove this as soon as we support opening a window from desktop app.
if (window.desktop) {
this.props.showExpandedView();
Expand All @@ -1129,9 +1139,11 @@ export default class CallWidget extends React.PureComponent<Props, State> {
});

expandedViewWindow?.addEventListener('beforeunload', () => {
this.props.trackEvent(Telemetry.Event.CloseExpandedView, Telemetry.Source.ExpandedView);
if (!window.callsClient) {
return;
}

const localScreenStream = window.callsClient.getLocalScreenStream();
if (localScreenStream && localScreenStream.getVideoTracks()[0].id === expandedViewWindow.screenSharingTrackId) {
window.callsClient.unshareScreen();
Expand All @@ -1140,20 +1152,23 @@ export default class CallWidget extends React.PureComponent<Props, State> {
}
}

onRaiseHandToggle = () => {
onRaiseHandToggle = (fromShortcut?: boolean) => {
if (!window.callsClient) {
return;
}
if (window.callsClient.isHandRaised) {
window.callsClient.unraiseHand();
this.props.trackEvent(Telemetry.Event.LowerHand, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'});
} else {
window.callsClient.raiseHand();
this.props.trackEvent(Telemetry.Event.RaiseHand, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'});
}
}

onChannelLinkClick = (ev: React.MouseEvent<HTMLElement>) => {
ev.preventDefault();
window.postMessage({type: 'browser-history-push-return', message: {pathName: this.props.channelURL}}, window.origin);
this.props.trackEvent(Telemetry.Event.OpenChannelLink, Telemetry.Source.Widget);
}

renderChannelName = (hasTeamSidebar: boolean) => {
Expand Down Expand Up @@ -1303,7 +1318,7 @@ export default class CallWidget extends React.PureComponent<Props, State> {
color: this.state.showParticipantsList ? 'rgba(28, 88, 217, 1)' : '',
background: this.state.showParticipantsList ? 'rgba(28, 88, 217, 0.12)' : '',
}}
onClick={this.onParticipantsButtonClick}
onClick={() => this.onParticipantsButtonClick()}
>
<ParticipantsIcon
style={{width: '16px', height: '16px', marginRight: '4px'}}
Expand All @@ -1328,7 +1343,7 @@ export default class CallWidget extends React.PureComponent<Props, State> {
>
<button
className='cursor--pointer style--none button-controls'
onClick={this.onRaiseHandToggle}
onClick={() => this.onRaiseHandToggle()}
style={{background: window.callsClient.isHandRaised ? 'rgba(255, 188, 66, 0.16)' : ''}}
>
<HandIcon
Expand Down
Loading

0 comments on commit e672052

Please sign in to comment.