diff --git a/jitsi-feedback-plugin/README.md b/jitsi-feedback-plugin/README.md index a2a5c4b..7249c74 100644 --- a/jitsi-feedback-plugin/README.md +++ b/jitsi-feedback-plugin/README.md @@ -12,6 +12,8 @@ If required by your special use case, it is technically possible to host `feedba These are the relevant settings that need to be set in jitsi-meet. The intended way to configure this is by leveraging the `custom-config.js` method. +Do not copy plugin.head.html into `/usr/share/jitsi-meet/` if the analytics system is enabled and third party requests are enabled. + ```javascript // address of the feedback backend REST API, reachable from the end user device config.feedbackBackend = 'https://example.org:8080' @@ -26,7 +28,7 @@ config.callStatsID = 'id'; // disables CallStats even if callStatsID is set config.callStatsSecret = null; -// this will enable analytics, both need to be false for our handler to work +// IMPORTANT: this will enable analytics, both need to be false for our handler to work config.disableThirdPartyRequests = false; config.analytics.disabled = false @@ -73,6 +75,23 @@ config.deploymentInfo = { ``` +### Deployment if third party requests are disabled + +If `config.disableThirdPartyRequests = true` the analytics system will be disabled. +Copy plugin.head.html into `/usr/share/jitsi-meet/plugin.head.html`. + +The plugin injects itself into Jitsi javascript functions and is more likely to break after upgrading the Jitsi frontend to a new version, +but it enables collecting the feedback even if it can't be sent using the analytics handlers. + +The above configuration has to be changed this way: +```js +config.disableThirdPartyRequests = true; + +// the handlers won't be loaded, so this may be removed +// config.analytics.scriptURLs = ['/feedback.js']; +``` + + ## Sample Data | Metadata | Value | diff --git a/jitsi-feedback-plugin/plugin.head.html b/jitsi-feedback-plugin/plugin.head.html index 1f75c31..c86ab54 100644 --- a/jitsi-feedback-plugin/plugin.head.html +++ b/jitsi-feedback-plugin/plugin.head.html @@ -15,5 +15,276 @@ --> \ No newline at end of file + console.log(`FEEDBACK html plugin loaded.`); + const LOG = 'FEEDBACK_INJECTOR'; + + // DON'T DEPLOY THIS PLUGIN AND feedback.js ON THE SAME SERVER, THEY WILL CAUSE CONFLICT. + // REMOVE feedback.js FROM config.analytics.scriptURLs + + // save the original function + const oldInitJitsiConference = window.JitsiMeetJS.JitsiConnection.prototype.initJitsiConference; + + + window.JitsiMeetJS.JitsiConnection.prototype.initJitsiConference = function () { + console.log('initJitsiConference', arguments); + + // leaving conference global allows us to collect metrics from here later + conference = oldInitJitsiConference.apply(this, arguments); + + // isCallstatsEnabled = true enables the feedback form when leaving the application + // note that in order to enable the feedback button in the toolbar, a callStatsID must be configured regardless + conference.statistics.isCallstatsEnabled = () => true; + + + const feedback = new Feedback(); + + // get the JWT on the conference initialization + feedback.sendEvent({ + action: 'connection.stage.reached', + actionSubject: 'conference_muc.joined' + }); + + + // overriding sendFeedback achieves 2 of our goals: + // 1. it lets us inject code in the right place (feedback submission) + // 2. it disables sending feedback to other backends, primarily callstats.io + conference.statistics.sendFeedback = async function (score, details) { + feedback.sendEvent({ + action: 'feedback', + actionSubject: 'feedback', + attributes: { + rating: score, + comment: details + } + }); + } + + return conference; + } + + + // ------------------------------------------------------------------------------------------ + + class Feedback { + constructor(options) { + this.sendEvent = this.sendEvent.bind(this); + this.setUserProperties = this.setUserProperties.bind(this); + } + + // called from lib-jitsi-meet + sendEvent(event) { + // console.log(`${LOG} handler`, event); + + if (event.action === 'connection.stage.reached' && event.actionSubject === 'conference_muc.joined') { + this.handleJoin(); + return; + } + + if (event.action === 'feedback' && event.actionSubject === 'feedback') { + this.handleFeedback(event.attributes); + return; + } + } + + // called from lib-jitsi-meet + setUserProperties(permanentProperties) { + // nothing + } + + handleFeedback(data) { + const {rating, comment} = data; + console.log(`${LOG} Feedback`, rating, comment); + + const jwt = window.APP.conference.feedbackToken; + console.log(`${LOG} gather metrics for JWT: ${jwt}`); + + const postFeedback = async (jwt, payload) => { + const baseUrl = APP.store.getState()['features/base/config'].feedbackBackend; + const url = `${baseUrl}/feedback`; + + const headers = { + 'authorization': `Bearer ${jwt}` + }; + + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + throw `${LOG} Status error: ${res.status}`; + } + + return res.text(); + }; + + const metrics = this._gatherMetrics(); + + const payload = { + rating: rating, + rating_comment: comment, + metadata: { + ...metrics + } + } + + postFeedback(jwt, payload) + .then(result => { + console.log(`${LOG} feedback result: `, payload); + }) + .catch(e => console.error(`${LOG} failed to feedback`, e)); + } + + handleJoin() { + // Extract matrix openId token from the Jitsi JWT token + const oidToken = this._getMatrixContext().matrix.token; + + console.log(`${LOG} Extracted matrix token: ${oidToken}`); + + const getToken = async (oidToken) => { + const baseUrl = APP.store.getState()['features/base/config'].feedbackBackend; + const url = `${baseUrl}/token`; + + const headers = { + 'authorization': `Bearer ${oidToken}` + }; + + const res = await fetch(url, { + method: 'GET', + headers + }); + + if (!res.ok) { + throw `${LOG} Status error: ${res.status}`; + } + + return res.text(); + }; + + // get the feedback JWT token as soon as possible + getToken(oidToken) + .then(feedbackToken => { + console.log(`${LOG} feedback JWT: ${feedbackToken}`); + window.APP.conference.feedbackToken = feedbackToken; + }) + .catch(e => console.error(`${LOG} failed to fetch JWT`, e)); + + return; + } + + _gatherMetrics() { + const metrics = {}; + const config = APP.store.getState()['features/base/config']; + const flags = config.metadata || []; + + flags.forEach(flag => { + try { + this._addMetric(flag, metrics) + } catch (e) { + console.error(`${LOG} Metrics error ${flag}:`, e); + } + }); + + return metrics; + } + + _getMatrixContext() { + const token = window.APP.store.getState()['features/base/jwt'].jwt; + const payload = token.split('.')[1]; + const content = JSON.parse(atob(payload)); + return content.context; + } + + _addMetric(flag, metrics) { + const config = APP.store.getState()['features/base/config']; + const conference = window.APP.store.getState()['features/base/conference'].conference; + const localParticipant = window.APP.store.getState()['features/base/participants'].local; + + switch (flag) { + // meetingId + case 'MEETING_URL': + metrics.meetingUrl = window.location.href; + break; + case 'MEETING_ID': + metrics.meetingId = JitsiMeetJS.analytics.permanentProperties.conference_name; + break; + + // participantID + case 'PARTICIPANT_ID': + metrics.participantId = conference.myUserId(); + break; + + // matrix user Id + case 'MATRIX_USER_ID': + metrics.matrixUserId = localParticipant.email; + break; + case 'DISPLAY_NAME': + metrics.displayName = localParticipant.name; + break; + + case 'USER_REGION': + metrics.userRegion = JitsiMeetJS.analytics.permanentProperties.userRegion; + break; + + // app data + case 'APP_LIB_VERSION': + metrics.appLibVersion = JitsiMeetJS.version; + break; + case 'APP_FOCUS_VERSION': + metrics.appFocusVersion = conference.componentsVersions.versions.focus; + break; + case 'APP_NAME': + metrics.appName = JitsiMeetJS.analytics.permanentProperties.appName; + break; + case 'APP_MEETING_REGION': + metrics.appMeetingRegion = config.deploymentInfo.region; + break; + case 'APP_SHARD': + metrics.appShard = config.deploymentInfo.shard; + break; + case 'APP_REGION': + metrics.appRegion = config.deploymentInfo.region; + break; + case 'APP_ENVIRONMENT': + metrics.appEnvironment = config.deploymentInfo.environment; + break; + case 'APP_ENV_TYPE': + metrics.appEnvType = config.deploymentInfo.envType; + break; + case 'APP_BACKEND_RELEASE': + metrics.appBackendRelease = config.deploymentInfo.backendRelease; + break; + + // browser, os + case 'USER_AGENT': + metrics.userAgent = JitsiMeetJS.analytics.permanentProperties.user_agent; + break; + case 'BROWSER_NAME': + metrics.browserName = JitsiMeetJS.util.browser.getName(); + break; + case 'BROWSER_VERSION': + metrics.browserVersion = JitsiMeetJS.util.browser.getVersion(); + break; + case 'OS_NAME': + metrics.osName = JitsiMeetJS.util.browser._bowser.parseOS().name; + break; + case 'OS_VERSION': + metrics.osVersion = JitsiMeetJS.util.browser._bowser.parseOS().version; + break; + case 'OS_VERSION_NAME': + metrics.osVersionName = JitsiMeetJS.util.browser._bowser.parseOS().versionName; + break; + + // standalone or embedded + case 'EXTERNAL_API': + metrics.externalApi = JitsiMeetJS.analytics.permanentProperties.externalApi; + break; + case 'IN_IFRAME': + metrics.inIframe = JitsiMeetJS.analytics.permanentProperties.inIframe; + break; + } + } + } + +