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.
This commit is contained in:
infinite-persistence 2021-10-21 23:17:17 +08:00
parent e14ec9b83e
commit b7685a151d
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
8 changed files with 219 additions and 47 deletions

View file

@ -1,6 +1,7 @@
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import Lbry from 'lbry'; import Lbry from 'lbry';
import querystring from 'querystring'; import querystring from 'querystring';
import analytics from 'analytics';
const Lbryio = { const Lbryio = {
enabled: true, enabled: true,
@ -81,7 +82,10 @@ Lbryio.call = (resource, action, params = {}, method = 'get') => {
url = `${Lbryio.CONNECTION_STRING}${resource}/${action}`; 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; 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; export default Lbryio;

View file

@ -1,7 +1,36 @@
// @flow // @flow
import { Lbryio } from 'lbryinc'; import { Lbryio } from 'lbryinc';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import * as RENDER_MODES from 'constants/file_render_modes';
import { SDK_API_PATH } from './index'; 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'; // import getConnectionSpeed from 'util/detect-user-bandwidth';
// let userDownloadBandwidthInBitsPerSecond; // let userDownloadBandwidthInBitsPerSecond;
@ -29,8 +58,8 @@ type Analytics = {
apiLogPublish: (ChannelClaim | StreamClaim) => void, apiLogPublish: (ChannelClaim | StreamClaim) => void,
apiSyncTags: ({}) => void, apiSyncTags: ({}) => void,
tagFollowEvent: (string, boolean, ?string) => void, tagFollowEvent: (string, boolean, ?string) => void,
playerLoadedEvent: (?boolean) => void, playerLoadedEvent: (string, ?boolean) => void,
playerStartedEvent: (?boolean) => void, playerVideoStartedEvent: (?boolean) => void,
videoStartEvent: (string, number, string, number, string, any, number) => void, videoStartEvent: (string, number, string, number, string, any, number) => void,
videoIsPlaying: (boolean, any) => void, videoIsPlaying: (boolean, any) => void,
videoBufferEvent: ( videoBufferEvent: (
@ -46,8 +75,6 @@ type Analytics = {
} }
) => Promise<any>, ) => Promise<any>,
adsFetchedEvent: () => void, adsFetchedEvent: () => void,
adsReceivedEvent: (any) => void,
adsErrorEvent: (any) => void,
emailProvidedEvent: () => void, emailProvidedEvent: () => void,
emailVerifiedEvent: () => void, emailVerifiedEvent: () => void,
rewardEligibleEvent: () => void, rewardEligibleEvent: () => void,
@ -55,6 +82,7 @@ type Analytics = {
purchaseEvent: (number) => void, purchaseEvent: (number) => void,
readyEvent: (number) => void, readyEvent: (number) => void,
openUrlEvent: (string) => void, openUrlEvent: (string) => void,
reportEvent: (string, any) => void,
}; };
type LogPublishParams = { type LogPublishParams = {
@ -67,6 +95,8 @@ type LogPublishParams = {
let internalAnalyticsEnabled: boolean = IS_WEB || false; let internalAnalyticsEnabled: boolean = IS_WEB || false;
// let thirdPartyAnalyticsEnabled: boolean = IS_WEB || false; // let thirdPartyAnalyticsEnabled: boolean = IS_WEB || false;
const isGaAllowed = internalAnalyticsEnabled && isProduction;
/** /**
* Determine the mobile device type viewing the data * Determine the mobile device type viewing the data
* This function returns one of 'and' (Android), 'ios', or 'web'. * This function returns one of 'and' (Android), 'ios', or 'web'.
@ -186,12 +216,8 @@ async function sendWatchmanData(body) {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
return response; return response;
} catch (err) { } catch (err) {}
console.log('ERROR FROM WATCHMAN BACKEND');
console.log(err);
}
} }
const analytics: Analytics = { const analytics: Analytics = {
@ -244,7 +270,6 @@ const analytics: Analytics = {
bitrateAsBitsPerSecond = videoBitrate; bitrateAsBitsPerSecond = videoBitrate;
sendPromMetric('time_to_start', timeToStartVideo); sendPromMetric('time_to_start', timeToStartVideo);
sendGaEvent('video_time_to_start', { claim_id: claimId, time: timeToStartVideo });
}, },
error: (message) => { error: (message) => {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -271,7 +296,7 @@ const analytics: Analytics = {
}); });
}, },
setUser: (userId) => { setUser: (userId) => {
if (internalAnalyticsEnabled && userId && window.gtag) { if (isGaAllowed && userId && window.gtag) {
window.gtag('set', { user_id: userId }); window.gtag('set', { user_id: userId });
} }
}, },
@ -339,54 +364,88 @@ const analytics: Analytics = {
adsFetchedEvent: () => { adsFetchedEvent: () => {
sendGaEvent('ad_fetched'); sendGaEvent('ad_fetched');
}, },
adsReceivedEvent: (response) => { playerLoadedEvent: (renderMode, embedded) => {
sendGaEvent('ad_received', { response: JSON.stringify(response) }); 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) => { playerVideoStartedEvent: (embedded) => {
sendGaEvent('ad_error', { response: JSON.stringify(response) }); sendGaEvent('player', {
}, [GA_DIMENSIONS.ACTION]: 'started_video',
playerLoadedEvent: (embedded) => { [GA_DIMENSIONS.TYPE]: embedded ? 'embedded' : 'onsite',
sendGaEvent('player', { action: 'loaded', type: embedded ? 'embedded' : 'onsite' }); });
},
playerStartedEvent: (embedded) => {
sendGaEvent('player', { action: 'started', type: embedded ? 'embedded' : 'onsite' });
}, },
tagFollowEvent: (tag, following) => { tagFollowEvent: (tag, following) => {
sendGaEvent(following ? 'tag_follow' : 'tag_unfollow', { tag }); sendGaEvent('tags', {
}, [GA_DIMENSIONS.ACTION]: following ? 'follow' : 'unfollow',
channelBlockEvent: (uri, blocked, location) => { [GA_DIMENSIONS.VALUE]: tag,
sendGaEvent(blocked ? 'channel_hidden' : 'channel_unhidden', { uri }); });
}, },
emailProvidedEvent: () => { emailProvidedEvent: () => {
sendGaEvent('engagement', { type: 'email_provided' }); sendGaEvent('engagement', {
[GA_DIMENSIONS.TYPE]: 'email_provided',
});
}, },
emailVerifiedEvent: () => { emailVerifiedEvent: () => {
sendGaEvent('engagement', { type: 'email_verified' }); sendGaEvent('engagement', {
[GA_DIMENSIONS.TYPE]: 'email_verified',
});
}, },
rewardEligibleEvent: () => { rewardEligibleEvent: () => {
sendGaEvent('engagement', { type: 'reward_eligible' }); sendGaEvent('engagement', {
[GA_DIMENSIONS.TYPE]: 'reward_eligible',
});
}, },
openUrlEvent: (url: string) => { openUrlEvent: (url: string) => {
sendGaEvent('engagement', { type: 'open_url', url }); sendGaEvent('engagement', {
[GA_DIMENSIONS.TYPE]: 'open_url',
url,
});
}, },
trendingAlgorithmEvent: (trendingAlgorithm: string) => { trendingAlgorithmEvent: (trendingAlgorithm: string) => {
sendGaEvent('engagement', { type: 'trending_algorithm', trending_algorithm: trendingAlgorithm }); sendGaEvent('engagement', {
[GA_DIMENSIONS.TYPE]: 'trending_algorithm',
trending_algorithm: trendingAlgorithm,
});
}, },
startupEvent: () => { startupEvent: () => {
// TODO: This can be removed (use the automated 'session_start' instead). // TODO: This can be removed (use the automated 'session_start' instead).
// sendGaEvent('startup', 'startup'); // sendGaEvent('app_diagnostics', 'startup');
}, },
readyEvent: (timeToReadyMs: number) => { readyEvent: (timeToReadyMs: number) => {
sendGaEvent('startup_app_ready', { time_to_ready_ms: timeToReadyMs }); sendGaEvent('diag_app_ready', {
[GA_DIMENSIONS.DURATION_MS]: timeToReadyMs,
});
}, },
purchaseEvent: (purchaseInt: number) => { purchaseEvent: (purchaseInt: number) => {
// https://developers.google.com/analytics/devguides/collection/ga4/reference/events#purchase sendGaEvent('purchase', {
sendGaEvent('purchase', { value: purchaseInt }); // 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 }) { function sendGaEvent(event: string, params?: { [string]: string | number }) {
if (internalAnalyticsEnabled && isProduction && window.gtag) { if (isGaAllowed && window.gtag) {
window.gtag('event', event, params); window.gtag('event', event, params);
} }
} }
@ -401,7 +460,7 @@ function sendPromMetric(name: string, value?: number) {
} }
// Activate // Activate
if (internalAnalyticsEnabled && isProduction && window.gtag) { if (isGaAllowed && window.gtag) {
window.gtag('consent', 'update', { window.gtag('consent', 'update', {
ad_storage: 'granted', ad_storage: 'granted',
analytics_storage: 'granted', analytics_storage: 'granted',

View file

@ -48,9 +48,9 @@ class FileRender extends React.PureComponent<Props> {
} }
componentDidMount() { componentDidMount() {
const { embedded } = this.props; const { renderMode, embedded } = this.props;
window.addEventListener('keydown', this.escapeListener, true); window.addEventListener('keydown', this.escapeListener, true);
analytics.playerLoadedEvent(embedded); analytics.playerLoadedEvent(renderMode, embedded);
} }
componentWillUnmount() { componentWillUnmount() {
@ -60,9 +60,7 @@ class FileRender extends React.PureComponent<Props> {
escapeListener(e: SyntheticKeyboardEvent<*>) { escapeListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ESCAPE) { if (e.keyCode === KEYCODES.ESCAPE) {
e.preventDefault(); e.preventDefault();
this.exitFullscreen(); this.exitFullscreen();
return false; return false;
} }
} }

View file

@ -179,8 +179,7 @@ function VideoViewer(props: Props) {
} }
} }
// send matomo event (embedded is boolean) analytics.playerVideoStartedEvent(embedded);
analytics.playerStartedEvent(embedded);
// convert bytes to bits, and then divide by seconds // convert bytes to bits, and then divide by seconds
const contentInBits = Number(claim.value.source.size) * 8; const contentInBits = Number(claim.value.source.size) * 8;
@ -189,12 +188,21 @@ function VideoViewer(props: Props) {
if (durationInSeconds) { if (durationInSeconds) {
bitrateAsBitsPerSecond = Math.round(contentInBits / 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) => { fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
// server string such as 'eu-p6' // server string such as 'eu-p6'
let playerPoweredBy = response.headers.get('x-powered-by') || ''; let playerPoweredBy = response.headers.get('x-powered-by') || '';
// populates data for watchman, sends prom and matomo event // 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 // hit backend to mark a view

View file

@ -24,6 +24,28 @@ import Lbry from 'lbry';
// import LbryFirst from 'extras/lbry-first/lbry-first'; // import LbryFirst from 'extras/lbry-first/lbry-first';
import { isClaimNsfw } from 'util/claim'; 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 NO_FILE = '---';
export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispatch: Dispatch, getState: () => {}) => { export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispatch: Dispatch, getState: () => {}) => {
const publishPreview = (previewResponse) => { const publishPreview = (previewResponse) => {
@ -56,6 +78,9 @@ export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispat
actions.push({ actions.push({
type: ACTIONS.PUBLISH_SUCCESS, 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 // We have to fake a temp claim until the new pending one is returned by claim_list_mine

View file

@ -347,9 +347,9 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho
const state = getState(); const state = getState();
const balance = selectBalance(state); const balance = selectBalance(state);
const myClaims = selectMyClaimsRaw(state); const myClaims = selectMyClaimsRaw(state);
const supportOwnClaim = myClaims ? myClaims.find((claim) => claim.claim_id === params.claim_id) : false;
const shouldSupport = const shouldSupport = isSupport || supportOwnClaim;
isSupport || (myClaims ? myClaims.find((claim) => claim.claim_id === params.claim_id) : false);
if (balance - params.amount <= 0) { if (balance - params.amount <= 0) {
dispatch( dispatch(
@ -376,6 +376,10 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho
dispatch({ dispatch({
type: ACTIONS.SUPPORT_TRANSACTION_COMPLETED, type: ACTIONS.SUPPORT_TRANSACTION_COMPLETED,
data: {
amount: params.amount,
type: shouldSupport ? (supportOwnClaim ? 'support_own' : 'support_others') : 'tip',
},
}); });
if (successCallback) { if (successCallback) {
@ -395,6 +399,7 @@ export function doSendTip(params, isSupport, successCallback, errorCallback, sho
type: ACTIONS.SUPPORT_TRANSACTION_FAILED, type: ACTIONS.SUPPORT_TRANSACTION_FAILED,
data: { data: {
error: err, error: err,
type: shouldSupport ? (supportOwnClaim ? 'support_own' : 'support_others') : 'tip',
}, },
}); });

View file

@ -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);
};
}

View file

@ -10,6 +10,7 @@ import { createMemoryHistory, createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router'; import { routerMiddleware } from 'connected-react-router';
import createRootReducer from './reducers'; import createRootReducer from './reducers';
import Lbry from 'lbry'; import Lbry from 'lbry';
import { createAnalyticsMiddleware } from 'redux/middleware/analytics';
import { buildSharedStateMiddleware } from 'redux/middleware/shared-state'; import { buildSharedStateMiddleware } from 'redux/middleware/shared-state';
import { doSyncLoop } from 'redux/actions/sync'; import { doSyncLoop } from 'redux/actions/sync';
import { getAuthToken } from 'util/saved-passwords'; import { getAuthToken } from 'util/saved-passwords';
@ -206,6 +207,8 @@ const sharedStateMiddleware = buildSharedStateMiddleware(triggerSharedStateActio
const rootReducer = createRootReducer(history); const rootReducer = createRootReducer(history);
const persistedReducer = persistReducer(persistOptions, rootReducer); const persistedReducer = persistReducer(persistOptions, rootReducer);
const bulkThunk = createBulkThunkMiddleware(); const bulkThunk = createBulkThunkMiddleware();
const analyticsMiddleware = createAnalyticsMiddleware();
const middleware = [ const middleware = [
sharedStateMiddleware, sharedStateMiddleware,
// @if TARGET='web' // @if TARGET='web'
@ -214,7 +217,9 @@ const middleware = [
routerMiddleware(history), routerMiddleware(history),
thunk, thunk,
bulkThunk, bulkThunk,
analyticsMiddleware,
]; ];
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore( const store = createStore(
enableBatching(persistedReducer), enableBatching(persistedReducer),