From b7685a151dfadd1b88c341b96aa7e9eec2950bab Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Thu, 21 Oct 2021 23:17:17 +0800 Subject: [PATCH] Additional GA events via redux/lbryio hook ## Issue 85 Add additional GA events ## Approach Instead of placing analytic calls all over the GUI code (no separation of concerns), try to do it through a redux middleware instead. ## Changes - Updated GA event and parameter naming after understanding how reporting works. - Removed unused analytics. --- extras/lbryinc/lbryio.js | 25 ++++- ui/analytics.js | 131 ++++++++++++++++------ ui/component/fileRender/view.jsx | 6 +- ui/component/viewers/videoViewer/view.jsx | 16 ++- ui/redux/actions/publish.js | 25 +++++ ui/redux/actions/wallet.js | 9 +- ui/redux/middleware/analytics.js | 49 ++++++++ ui/store.js | 5 + 8 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 ui/redux/middleware/analytics.js diff --git a/extras/lbryinc/lbryio.js b/extras/lbryinc/lbryio.js index 770e2b6e8..b8884bc14 100644 --- a/extras/lbryinc/lbryio.js +++ b/extras/lbryinc/lbryio.js @@ -1,6 +1,7 @@ import * as ACTIONS from 'constants/action_types'; import Lbry from 'lbry'; import querystring from 'querystring'; +import analytics from 'analytics'; const Lbryio = { enabled: true, @@ -81,7 +82,10 @@ Lbryio.call = (resource, action, params = {}, method = 'get') => { url = `${Lbryio.CONNECTION_STRING}${resource}/${action}`; } - return makeRequest(url, options).then((response) => response.data); + return makeRequest(url, options).then((response) => { + sendCallAnalytics(resource, action, params); + return response.data; + }); }); }; @@ -233,4 +237,23 @@ Lbryio.setOverride = (methodName, newMethod) => { Lbryio.overrides[methodName] = newMethod; }; +function sendCallAnalytics(resource, action, params) { + switch (resource) { + case 'customer': + if (action === 'tip') { + analytics.reportEvent('spend_virtual_currency', { + // https://developers.google.com/analytics/devguides/collection/ga4/reference/events#spend_virtual_currency + value: params.amount, + virtual_currency_name: params.currency.toLowerCase(), + item_name: 'tip', + }); + } + break; + + default: + // Do nothing + break; + } +} + export default Lbryio; diff --git a/ui/analytics.js b/ui/analytics.js index fc162d716..2fb6575c6 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -1,7 +1,36 @@ // @flow import { Lbryio } from 'lbryinc'; import * as Sentry from '@sentry/browser'; +import * as RENDER_MODES from 'constants/file_render_modes'; import { SDK_API_PATH } from './index'; + +// --- GA --- +// - Events: 500 max (cannot be deleted). +// - Dimensions: 25 max (cannot be deleted, but can be "archived"). Usually +// tied to an event parameter for reporting purposes. +// +// Given the limitations above, we need to plan ahead before adding new Events +// and Parameters. +// +// Events: +// - Find a Recommended Event that is closest to what you need. +// https://support.google.com/analytics/answer/9267735?hl=en +// - If doesn't exist, use a Custom Event. +// +// Parameters: +// - Custom parameters don't appear in automated reports until they are tied to +// a Dimension. +// - Add your entry to GA_DIMENSIONS below -- tt allows us to keep track so that +// we don't exceed the limit. Re-use existing parameters if possible. +// - Register the Dimension in GA Console to make it visible in reports. + +export const GA_DIMENSIONS = { + TYPE: 'type', + ACTION: 'action', + VALUE: 'value', + DURATION_MS: 'duration_ms', +}; + // import getConnectionSpeed from 'util/detect-user-bandwidth'; // let userDownloadBandwidthInBitsPerSecond; @@ -29,8 +58,8 @@ type Analytics = { apiLogPublish: (ChannelClaim | StreamClaim) => void, apiSyncTags: ({}) => void, tagFollowEvent: (string, boolean, ?string) => void, - playerLoadedEvent: (?boolean) => void, - playerStartedEvent: (?boolean) => void, + playerLoadedEvent: (string, ?boolean) => void, + playerVideoStartedEvent: (?boolean) => void, videoStartEvent: (string, number, string, number, string, any, number) => void, videoIsPlaying: (boolean, any) => void, videoBufferEvent: ( @@ -46,8 +75,6 @@ type Analytics = { } ) => Promise, adsFetchedEvent: () => void, - adsReceivedEvent: (any) => void, - adsErrorEvent: (any) => void, emailProvidedEvent: () => void, emailVerifiedEvent: () => void, rewardEligibleEvent: () => void, @@ -55,6 +82,7 @@ type Analytics = { purchaseEvent: (number) => void, readyEvent: (number) => void, openUrlEvent: (string) => void, + reportEvent: (string, any) => void, }; type LogPublishParams = { @@ -67,6 +95,8 @@ type LogPublishParams = { let internalAnalyticsEnabled: boolean = IS_WEB || false; // let thirdPartyAnalyticsEnabled: boolean = IS_WEB || false; +const isGaAllowed = internalAnalyticsEnabled && isProduction; + /** * Determine the mobile device type viewing the data * This function returns one of 'and' (Android), 'ios', or 'web'. @@ -186,12 +216,8 @@ async function sendWatchmanData(body) { }, body: JSON.stringify(body), }); - return response; - } catch (err) { - console.log('ERROR FROM WATCHMAN BACKEND'); - console.log(err); - } + } catch (err) {} } const analytics: Analytics = { @@ -244,7 +270,6 @@ const analytics: Analytics = { bitrateAsBitsPerSecond = videoBitrate; sendPromMetric('time_to_start', timeToStartVideo); - sendGaEvent('video_time_to_start', { claim_id: claimId, time: timeToStartVideo }); }, error: (message) => { return new Promise((resolve) => { @@ -271,7 +296,7 @@ const analytics: Analytics = { }); }, setUser: (userId) => { - if (internalAnalyticsEnabled && userId && window.gtag) { + if (isGaAllowed && userId && window.gtag) { window.gtag('set', { user_id: userId }); } }, @@ -339,54 +364,88 @@ const analytics: Analytics = { adsFetchedEvent: () => { sendGaEvent('ad_fetched'); }, - adsReceivedEvent: (response) => { - sendGaEvent('ad_received', { response: JSON.stringify(response) }); + playerLoadedEvent: (renderMode, embedded) => { + const RENDER_MODE_TO_EVENT = (renderMode) => { + switch (renderMode) { + case RENDER_MODES.VIDEO: + return 'loaded_video'; + case RENDER_MODES.AUDIO: + return 'loaded_audio'; + case RENDER_MODES.MARKDOWN: + return 'loaded_markdown'; + case RENDER_MODES.IMAGE: + return 'loaded_image'; + default: + return 'loaded_misc'; + } + }; + + sendGaEvent('player', { + [GA_DIMENSIONS.ACTION]: RENDER_MODE_TO_EVENT(renderMode), + [GA_DIMENSIONS.TYPE]: embedded ? 'embedded' : 'onsite', + }); }, - adsErrorEvent: (response) => { - sendGaEvent('ad_error', { response: JSON.stringify(response) }); - }, - playerLoadedEvent: (embedded) => { - sendGaEvent('player', { action: 'loaded', type: embedded ? 'embedded' : 'onsite' }); - }, - playerStartedEvent: (embedded) => { - sendGaEvent('player', { action: 'started', type: embedded ? 'embedded' : 'onsite' }); + playerVideoStartedEvent: (embedded) => { + sendGaEvent('player', { + [GA_DIMENSIONS.ACTION]: 'started_video', + [GA_DIMENSIONS.TYPE]: embedded ? 'embedded' : 'onsite', + }); }, tagFollowEvent: (tag, following) => { - sendGaEvent(following ? 'tag_follow' : 'tag_unfollow', { tag }); - }, - channelBlockEvent: (uri, blocked, location) => { - sendGaEvent(blocked ? 'channel_hidden' : 'channel_unhidden', { uri }); + sendGaEvent('tags', { + [GA_DIMENSIONS.ACTION]: following ? 'follow' : 'unfollow', + [GA_DIMENSIONS.VALUE]: tag, + }); }, emailProvidedEvent: () => { - sendGaEvent('engagement', { type: 'email_provided' }); + sendGaEvent('engagement', { + [GA_DIMENSIONS.TYPE]: 'email_provided', + }); }, emailVerifiedEvent: () => { - sendGaEvent('engagement', { type: 'email_verified' }); + sendGaEvent('engagement', { + [GA_DIMENSIONS.TYPE]: 'email_verified', + }); }, rewardEligibleEvent: () => { - sendGaEvent('engagement', { type: 'reward_eligible' }); + sendGaEvent('engagement', { + [GA_DIMENSIONS.TYPE]: 'reward_eligible', + }); }, openUrlEvent: (url: string) => { - sendGaEvent('engagement', { type: 'open_url', url }); + sendGaEvent('engagement', { + [GA_DIMENSIONS.TYPE]: 'open_url', + url, + }); }, trendingAlgorithmEvent: (trendingAlgorithm: string) => { - sendGaEvent('engagement', { type: 'trending_algorithm', trending_algorithm: trendingAlgorithm }); + sendGaEvent('engagement', { + [GA_DIMENSIONS.TYPE]: 'trending_algorithm', + trending_algorithm: trendingAlgorithm, + }); }, startupEvent: () => { // TODO: This can be removed (use the automated 'session_start' instead). - // sendGaEvent('startup', 'startup'); + // sendGaEvent('app_diagnostics', 'startup'); }, readyEvent: (timeToReadyMs: number) => { - sendGaEvent('startup_app_ready', { time_to_ready_ms: timeToReadyMs }); + sendGaEvent('diag_app_ready', { + [GA_DIMENSIONS.DURATION_MS]: timeToReadyMs, + }); }, purchaseEvent: (purchaseInt: number) => { - // https://developers.google.com/analytics/devguides/collection/ga4/reference/events#purchase - sendGaEvent('purchase', { value: purchaseInt }); + sendGaEvent('purchase', { + // https://developers.google.com/analytics/devguides/collection/ga4/reference/events#purchase + [GA_DIMENSIONS.VALUE]: purchaseInt, + }); + }, + reportEvent: (event: string, params?: { [string]: string | number }) => { + sendGaEvent(event, params); }, }; function sendGaEvent(event: string, params?: { [string]: string | number }) { - if (internalAnalyticsEnabled && isProduction && window.gtag) { + if (isGaAllowed && window.gtag) { window.gtag('event', event, params); } } @@ -401,7 +460,7 @@ function sendPromMetric(name: string, value?: number) { } // Activate -if (internalAnalyticsEnabled && isProduction && window.gtag) { +if (isGaAllowed && window.gtag) { window.gtag('consent', 'update', { ad_storage: 'granted', analytics_storage: 'granted', diff --git a/ui/component/fileRender/view.jsx b/ui/component/fileRender/view.jsx index 5a946f868..64ad93cbf 100644 --- a/ui/component/fileRender/view.jsx +++ b/ui/component/fileRender/view.jsx @@ -48,9 +48,9 @@ class FileRender extends React.PureComponent { } componentDidMount() { - const { embedded } = this.props; + const { renderMode, embedded } = this.props; window.addEventListener('keydown', this.escapeListener, true); - analytics.playerLoadedEvent(embedded); + analytics.playerLoadedEvent(renderMode, embedded); } componentWillUnmount() { @@ -60,9 +60,7 @@ class FileRender extends React.PureComponent { escapeListener(e: SyntheticKeyboardEvent<*>) { if (e.keyCode === KEYCODES.ESCAPE) { e.preventDefault(); - this.exitFullscreen(); - return false; } } diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 50a1a2c5d..7f940ffa6 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -179,8 +179,7 @@ function VideoViewer(props: Props) { } } - // send matomo event (embedded is boolean) - analytics.playerStartedEvent(embedded); + analytics.playerVideoStartedEvent(embedded); // convert bytes to bits, and then divide by seconds const contentInBits = Number(claim.value.source.size) * 8; @@ -189,12 +188,21 @@ function VideoViewer(props: Props) { if (durationInSeconds) { bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds); } -// figure out what server the video is served from and then run start analytic event + + // figure out what server the video is served from and then run start analytic event fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => { // server string such as 'eu-p6' let playerPoweredBy = response.headers.get('x-powered-by') || ''; // populates data for watchman, sends prom and matomo event - analytics.videoStartEvent(claimId, timeToStartVideo, playerPoweredBy, userId, claim.canonical_url, this, bitrateAsBitsPerSecond); + analytics.videoStartEvent( + claimId, + timeToStartVideo, + playerPoweredBy, + userId, + claim.canonical_url, + this, + bitrateAsBitsPerSecond + ); }); // hit backend to mark a view diff --git a/ui/redux/actions/publish.js b/ui/redux/actions/publish.js index 9078421b0..b4e4eac60 100644 --- a/ui/redux/actions/publish.js +++ b/ui/redux/actions/publish.js @@ -24,6 +24,28 @@ import Lbry from 'lbry'; // import LbryFirst from 'extras/lbry-first/lbry-first'; import { isClaimNsfw } from 'util/claim'; +function resolveClaimTypeForAnalytics(claim) { + if (!claim) { + return 'undefined_claim'; + } + + switch (claim.value_type) { + case 'stream': + if (claim.value) { + if (!claim.value.source) { + return 'livestream'; + } else { + return claim.value.stream_type; + } + } else { + return 'stream'; + } + default: + // collection, channel, repost, undefined + return claim.value_type; + } +} + export const NO_FILE = '---'; export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispatch: Dispatch, getState: () => {}) => { const publishPreview = (previewResponse) => { @@ -56,6 +78,9 @@ export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispat actions.push({ type: ACTIONS.PUBLISH_SUCCESS, + data: { + type: resolveClaimTypeForAnalytics(pendingClaim), + }, }); // We have to fake a temp claim until the new pending one is returned by claim_list_mine diff --git a/ui/redux/actions/wallet.js b/ui/redux/actions/wallet.js index f8cb70c61..b373b6fb8 100644 --- a/ui/redux/actions/wallet.js +++ b/ui/redux/actions/wallet.js @@ -347,9 +347,9 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho const state = getState(); const balance = selectBalance(state); const myClaims = selectMyClaimsRaw(state); + const supportOwnClaim = myClaims ? myClaims.find((claim) => claim.claim_id === params.claim_id) : false; - const shouldSupport = - isSupport || (myClaims ? myClaims.find((claim) => claim.claim_id === params.claim_id) : false); + const shouldSupport = isSupport || supportOwnClaim; if (balance - params.amount <= 0) { dispatch( @@ -376,6 +376,10 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho dispatch({ type: ACTIONS.SUPPORT_TRANSACTION_COMPLETED, + data: { + amount: params.amount, + type: shouldSupport ? (supportOwnClaim ? 'support_own' : 'support_others') : 'tip', + }, }); if (successCallback) { @@ -395,6 +399,7 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho type: ACTIONS.SUPPORT_TRANSACTION_FAILED, data: { error: err, + type: shouldSupport ? (supportOwnClaim ? 'support_own' : 'support_others') : 'tip', }, }); diff --git a/ui/redux/middleware/analytics.js b/ui/redux/middleware/analytics.js new file mode 100644 index 000000000..5d10ded51 --- /dev/null +++ b/ui/redux/middleware/analytics.js @@ -0,0 +1,49 @@ +// @flow +import analytics, { GA_DIMENSIONS } from 'analytics'; +import * as ACTIONS from 'constants/action_types'; + +export function createAnalyticsMiddleware() { + return (/* { dispatch, getState } */) => (next: any) => (action: { type: string, data: any }) => { + switch (action.type) { + case ACTIONS.SUPPORT_TRANSACTION_COMPLETED: + const { amount, type } = action.data; + analytics.reportEvent('spend_virtual_currency', { + // https://developers.google.com/analytics/devguides/collection/ga4/reference/events#spend_virtual_currency + value: amount, + virtual_currency_name: 'lbc', + item_name: type, + }); + break; + + case ACTIONS.COMMENT_CREATE_COMPLETED: + analytics.reportEvent('comments', { + [GA_DIMENSIONS.TYPE]: 'create', + }); + break; + + case ACTIONS.COMMENT_CREATE_FAILED: + analytics.reportEvent('comments', { + [GA_DIMENSIONS.TYPE]: 'create_fail', + }); + break; + + case ACTIONS.PUBLISH_SUCCESS: + analytics.reportEvent('publish', { + [GA_DIMENSIONS.TYPE]: 'publish_success', + }); + break; + + case ACTIONS.PUBLISH_FAIL: + analytics.reportEvent('publish', { + [GA_DIMENSIONS.TYPE]: 'publish_fail', + }); + break; + + default: + // Do nothing + break; + } + + return next(action); + }; +} diff --git a/ui/store.js b/ui/store.js index bf78ea949..13f9e24a8 100644 --- a/ui/store.js +++ b/ui/store.js @@ -10,6 +10,7 @@ import { createMemoryHistory, createBrowserHistory } from 'history'; import { routerMiddleware } from 'connected-react-router'; import createRootReducer from './reducers'; import Lbry from 'lbry'; +import { createAnalyticsMiddleware } from 'redux/middleware/analytics'; import { buildSharedStateMiddleware } from 'redux/middleware/shared-state'; import { doSyncLoop } from 'redux/actions/sync'; import { getAuthToken } from 'util/saved-passwords'; @@ -206,6 +207,8 @@ const sharedStateMiddleware = buildSharedStateMiddleware(triggerSharedStateActio const rootReducer = createRootReducer(history); const persistedReducer = persistReducer(persistOptions, rootReducer); const bulkThunk = createBulkThunkMiddleware(); +const analyticsMiddleware = createAnalyticsMiddleware(); + const middleware = [ sharedStateMiddleware, // @if TARGET='web' @@ -214,7 +217,9 @@ const middleware = [ routerMiddleware(history), thunk, bulkThunk, + analyticsMiddleware, ]; + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const store = createStore( enableBatching(persistedReducer),