diff --git a/server/api.go b/server/api.go index 655a0263a..285435e7f 100644 --- a/server/api.go +++ b/server/api.go @@ -6,7 +6,6 @@ package main import ( "encoding/json" "fmt" - "io/ioutil" "net/http" "net/http/pprof" "regexp" @@ -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"` @@ -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"` } @@ -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 @@ -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()) } } @@ -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) diff --git a/server/telemetry.go b/server/telemetry.go index 5dadf8280..bce63976e 100644 --- a/server/telemetry.go +++ b/server/telemetry.go @@ -4,10 +4,14 @@ 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" @@ -15,6 +19,41 @@ const ( 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() @@ -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" +} diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index 6a2d0f9c5..6f362831e 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -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, diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 4cb23cfe8..e3374c328 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -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'; @@ -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) => { + 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)}, + ); + }; +}; diff --git a/webapp/src/components/call_widget/component.tsx b/webapp/src/components/call_widget/component.tsx index aebad16d0..19823dc85 100644 --- a/webapp/src/components/call_widget/component.tsx +++ b/webapp/src/components/call_widget/component.tsx @@ -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, @@ -68,6 +69,7 @@ interface Props { show: boolean, showExpandedView: () => void, showScreenSourceModal: () => void, + trackEvent: (event: Telemetry.Event, source: Telemetry.Source, props?: Record) => void, } interface DraggingState { @@ -256,13 +258,13 @@ export default class CallWidget extends React.PureComponent { 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(); @@ -422,11 +424,13 @@ export default class CallWidget extends React.PureComponent { 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(); @@ -434,6 +438,7 @@ export default class CallWidget extends React.PureComponent { 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({ @@ -486,7 +491,10 @@ export default class CallWidget extends React.PureComponent { }); } - 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, @@ -566,7 +574,7 @@ export default class CallWidget extends React.PureComponent { borderRadius: '4px', fontWeight: 600, }} - onClick={this.onShareScreenToggle} + onClick={() => this.onShareScreenToggle()} > {'Stop sharing'} @@ -651,7 +659,7 @@ export default class CallWidget extends React.PureComponent { 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()} > { color: sharingID !== '' && !isSharing ? changeOpacity(this.props.theme.centerChannelColor, 0.34) : '', }} disabled={Boolean(sharingID !== '' && !isSharing)} - onClick={this.onShareScreenToggle} + onClick={() => this.onShareScreenToggle()} > { 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(); @@ -1129,9 +1139,11 @@ export default class CallWidget extends React.PureComponent { }); 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(); @@ -1140,20 +1152,23 @@ export default class CallWidget extends React.PureComponent { } } - 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) => { 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) => { @@ -1303,7 +1318,7 @@ export default class CallWidget extends React.PureComponent { 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()} > { > @@ -512,7 +525,7 @@ export default class ExpandedView extends React.PureComponent {