Additional GA events via redux/lbryio hook (#110)
This commit is contained in:
commit
b4f62e78de
8 changed files with 237 additions and 47 deletions
|
@ -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;
|
||||
|
|
129
ui/analytics.js
129
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<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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
67
ui/redux/middleware/analytics.js
Normal file
67
ui/redux/middleware/analytics.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
// @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,
|
||||
actions: Array<any>,
|
||||
}) => {
|
||||
if (action.type === 'BATCH_ACTIONS') {
|
||||
action.actions.forEach((a) => handleAnalyticsForAction(a));
|
||||
} else {
|
||||
handleAnalyticsForAction(action);
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
}
|
||||
|
||||
function handleAnalyticsForAction(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.ACTION]: 'create',
|
||||
});
|
||||
break;
|
||||
|
||||
case ACTIONS.COMMENT_CREATE_FAILED:
|
||||
analytics.reportEvent('comments', {
|
||||
[GA_DIMENSIONS.ACTION]: 'create_fail',
|
||||
});
|
||||
break;
|
||||
|
||||
case ACTIONS.PUBLISH_SUCCESS:
|
||||
{
|
||||
const { type } = action.data;
|
||||
analytics.reportEvent('publish', {
|
||||
[GA_DIMENSIONS.ACTION]: 'publish_success',
|
||||
[GA_DIMENSIONS.TYPE]: type,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case ACTIONS.PUBLISH_FAIL:
|
||||
analytics.reportEvent('publish', {
|
||||
[GA_DIMENSIONS.ACTION]: 'publish_fail',
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Do nothing
|
||||
break;
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue