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 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;

View file

@ -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<any>,
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) => {
sendGaEvent('purchase', {
// https://developers.google.com/analytics/devguides/collection/ga4/reference/events#purchase
sendGaEvent('purchase', { value: purchaseInt });
[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',

View file

@ -48,9 +48,9 @@ class FileRender extends React.PureComponent<Props> {
}
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<Props> {
escapeListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ESCAPE) {
e.preventDefault();
this.exitFullscreen();
return false;
}
}

View file

@ -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
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

View file

@ -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

View file

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

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 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),