Skip to content

Commit

Permalink
Merge pull request #819 from blockscout/feat/mixpanel
Browse files Browse the repository at this point in the history
Feature: mixpanel analytics
  • Loading branch information
tom2drum authored May 23, 2023
2 parents ee02cfa + 2fb0ed0 commit 7573a30
Show file tree
Hide file tree
Showing 27 changed files with 311 additions and 34 deletions.
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ NEXT_PUBLIC_SENTRY_DSN=xxx
SENTRY_CSP_REPORT_URI=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ NEXT_PUBLIC_AUTH0_CLIENT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_AUTH0_CLIENT_ID__
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID__
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=__PLACEHOLDER_FOR_NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY__
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID__
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=__PLACEHOLDER_FOR_NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN__

# l2 config
NEXT_PUBLIC_IS_L2_NETWORK=__PLACEHOLDER_FOR_NEXT_PUBLIC_IS_L2_NETWORKL__
Expand Down
3 changes: 3 additions & 0 deletions configs/app/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ const config = Object.freeze({
googleAnalytics: {
propertyId: getEnvValue(process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID),
},
mixpanel: {
projectToken: getEnvValue(process.env.NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN),
},
graphQL: {
defaultTxnHash: getEnvValue(process.env.NEXT_PUBLIC_GRAPHIQL_TRANSACTION) || '',
},
Expand Down
1 change: 1 addition & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ For each application, you need to specify the `MarketplaceCategoryId` to which i
| NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID | `string` | Project id for [WalletConnect](https://docs.walletconnect.com/2.0/web3modal/react/installation#obtain-project-id) integration | - | - | `<secret>` |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Site key for [reCAPTCHA](https://developers.google.com/recaptcha) service | - | - | `<secret>` |
| NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID | `string` | Property ID for [Google Analytics](https://analytics.google.com/) service | - | - | `UA-XXXXXX-X` |
| NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN | `string` | Project token for [Mixpanel](https://mixpanel.com/) analytics service | - | - | `<secret>` |

## L2 configuration
*Note* All variables are required only for roll-up instances
Expand Down
3 changes: 3 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ declare global {
providers?: Array<ExternalProvider>;
};
coinzilla_display: Array<CPreferences>;
ga?: {
getAll: () => Array<{ get: (prop: string) => string }>;
};
}
}
2 changes: 1 addition & 1 deletion icons/error-pages/403.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions lib/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum NAMES {
COLOR_MODE='chakra-ui-color-mode',
INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug',
}

export function get(name?: NAMES | undefined | null, serverCookie?: string) {
Expand Down
1 change: 1 addition & 0 deletions lib/csp/generateCspPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function generateCspPolicy() {
descriptors.googleAnalytics(),
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.mixpanel(),
descriptors.monaco(),
descriptors.sentry(),
descriptors.walletConnect(),
Expand Down
1 change: 1 addition & 0 deletions lib/csp/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { cloudFlare } from './cloudFlare';
export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
export { sentry } from './sentry';
export { walletConnect } from './walletConnect';
15 changes: 15 additions & 0 deletions lib/csp/policies/mixpanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type CspDev from 'csp-dev';

import appConfig from 'configs/app/config';

export function mixpanel(): CspDev.DirectiveDescriptor {
if (!appConfig.mixpanel.projectToken) {
return {};
}

return {
'connect-src': [
'*.mixpanel.com',
],
};
}
3 changes: 3 additions & 0 deletions lib/mixpanel/getGoogleAnalyticsClientId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function getGoogleAnalyticsClientId() {
return window.ga?.getAll()[0].get('clientId');
}
47 changes: 47 additions & 0 deletions lib/mixpanel/getPageType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Route } from 'nextjs-routes';

const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/': 'Homepage',
'/txs': 'Transactions',
'/tx/[hash]': 'Transaction details',
'/blocks': 'Blocks',
'/block/[height]': 'Block details',
'/accounts': 'Top accounts',
'/address/[hash]': 'Address details',
'/verified-contracts': 'Verified contracts',
'/address/[hash]/contract_verification': 'Contract verification',
'/tokens': 'Tokens',
'/token/[hash]': 'Token details',
'/token/[hash]/instance/[id]': 'Token Instance',
'/apps': 'Apps',
'/apps/[id]': 'App',
'/stats': 'Stats',
'/api-docs': 'REST API',
'/graphiql': 'GraphQL',
'/search-results': 'Search results',
'/auth/profile': 'Profile',
'/account/watchlist': 'Watchlist',
'/account/api_key': 'API keys',
'/account/custom_abi': 'Custom ABI',
'/account/public_tags_request': 'Public tags',
'/account/tag_address': 'Private tags',
'/account/verified_addresses': 'Verified addresses',
'/withdrawals': 'Withdrawals',
'/visualize/sol2uml': 'Solidity UML diagram',
'/csv-export': 'Export data to CSV file',
'/l2-deposits': 'Deposits (L1 > L2)',
'/l2-output-roots': 'Output roots',
'/l2-txn-batches': 'Tx batches (L2 blocks)',
'/l2-withdrawals': 'Withdrawals (L2 > L1)',

// service routes, added only to make typescript happy
'/login': 'Login',
'/api/media-type': 'Node API: Media type',
'/api/proxy': 'Node API: Proxy',
'/api/csrf': 'Node API: CSRF token',
'/auth/auth0': 'Auth',
};

export default function getPageType(pathname: Route['pathname']) {
return PAGE_TYPE_DICT[pathname] || 'Unknown page';
}
5 changes: 5 additions & 0 deletions lib/mixpanel/getTabName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import _capitalize from 'lodash/capitalize';

export default function getTabName(tab: string) {
return tab !== '' ? _capitalize(tab.replaceAll('_', ' ')) : 'Default';
}
12 changes: 12 additions & 0 deletions lib/mixpanel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import getPageType from './getPageType';
import logEvent from './logEvent';
import useInit from './useInit';
import useLogPageView from './useLogPageView';
export * from './utils';

export {
useInit,
useLogPageView,
logEvent,
getPageType,
};
14 changes: 14 additions & 0 deletions lib/mixpanel/logEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import mixpanel from 'mixpanel-browser';

import type { EventTypes, EventPayload } from './utils';

type TrackFnArgs = Parameters<typeof mixpanel.track>;

export default function logEvent<EventType extends EventTypes>(
type: EventType,
properties?: EventPayload<EventType>,
optionsOrCallback?: TrackFnArgs[2],
callback?: TrackFnArgs[3],
) {
mixpanel.track(type, properties, optionsOrCallback, callback);
}
52 changes: 52 additions & 0 deletions lib/mixpanel/useInit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import _capitalize from 'lodash/capitalize';
import type { Config } from 'mixpanel-browser';
import mixpanel from 'mixpanel-browser';
import { useRouter } from 'next/router';
import React from 'react';
import { deviceType } from 'react-device-detect';

import appConfig from 'configs/app/config';
import * as cookies from 'lib/cookies';
import getQueryParamString from 'lib/router/getQueryParamString';

import getGoogleAnalyticsClientId from './getGoogleAnalyticsClientId';

export default function useMixpanelInit() {
const [ isInited, setIsInited ] = React.useState(false);
const router = useRouter();
const debugFlagQuery = React.useRef(getQueryParamString(router.query._mixpanel_debug));

React.useEffect(() => {
if (!appConfig.mixpanel.projectToken) {
return;
}

const debugFlagCookie = cookies.get(cookies.NAMES.MIXPANEL_DEBUG);

const config: Partial<Config> = {
debug: Boolean(debugFlagQuery.current || debugFlagCookie),
};
const isAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN));
const userId = getGoogleAnalyticsClientId();

mixpanel.init(appConfig.mixpanel.projectToken, config);
mixpanel.register({
'Chain id': appConfig.network.id,
Environment: appConfig.isDev ? 'Dev' : 'Prod',
Authorized: isAuth,
'Viewport width': window.innerWidth,
'Viewport height': window.innerHeight,
Language: window.navigator.language,
'User id': userId,
'Device type': _capitalize(deviceType),
});

setIsInited(true);

if (debugFlagQuery.current && !debugFlagCookie) {
cookies.set(cookies.NAMES.MIXPANEL_DEBUG, 'true');
}
}, []);

return isInited;
}
36 changes: 36 additions & 0 deletions lib/mixpanel/useLogPageView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';

import appConfig from 'configs/app/config';
import getQueryParamString from 'lib/router/getQueryParamString';

import getPageType from './getPageType';
import getTabName from './getTabName';
import logEvent from './logEvent';
import { EventTypes } from './utils';

export default function useLogPageView(isInited: boolean) {
const router = useRouter();
const pathname = usePathname();

const tab = getQueryParamString(router.query.tab);
const page = getQueryParamString(router.query.page);

React.useEffect(() => {
if (!appConfig.mixpanel.projectToken || !isInited) {
return;
}

logEvent(EventTypes.PAGE_VIEW, {
'Page type': getPageType(router.pathname),
Tab: getTabName(tab),
Page: page || undefined,
});
// these are only deps that should trigger the effect
// in some scenarios page type is not changing (e.g navigation from one address page to another),
// but we still want to log page view
// so we use pathname from 'next/navigation' instead of router.pathname from 'next/router' as deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isInited, page, pathname, tab ]);
}
18 changes: 18 additions & 0 deletions lib/mixpanel/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export enum EventTypes {
PAGE_VIEW = 'Page view',
SEARCH_QUERY = 'Search query',
}

export type EventPayload<Type extends EventTypes> =
Type extends EventTypes.PAGE_VIEW ?
{
'Page type': string;
'Tab': string;
'Page'?: string;
} :
Type extends EventTypes.SEARCH_QUERY ? {
'Search query': string;
'Source page type': string;
'Result URL': string;
} :
undefined;
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"graphql-ws": "^5.11.3",
"js-cookie": "^3.0.1",
"lodash": "^4.0.0",
"mixpanel-browser": "^2.47.0",
"monaco-editor": "^0.34.1",
"next": "13.3.0",
"nextjs-routes": "^1.0.8",
Expand All @@ -68,6 +69,7 @@
"pino-pretty": "^9.1.1",
"qrcode": "^1.5.1",
"react": "18.2.0",
"react-device-detect": "^2.2.3",
"react-dom": "18.2.0",
"react-google-recaptcha": "^2.1.0",
"react-hook-form": "^7.33.1",
Expand All @@ -88,6 +90,7 @@
"@types/dom-to-image": "^2.6.4",
"@types/jest": "^29.2.0",
"@types/js-cookie": "^3.0.2",
"@types/mixpanel-browser": "^2.38.1",
"@types/node": "18.11.18",
"@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0",
Expand Down
2 changes: 2 additions & 0 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
import 'lib/setLocale';

function MyApp({ Component, pageProps }: AppProps) {

useConfigSentry();

const [ queryClient ] = useState(() => new QueryClient({
defaultOptions: {
queries: {
Expand Down
6 changes: 6 additions & 0 deletions ui/pages/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { VStack, Textarea, Button, Alert, AlertTitle, AlertDescription, Code, Flex, Box } from '@chakra-ui/react';
import * as Sentry from '@sentry/react';
import mixpanel from 'mixpanel-browser';
import type { ChangeEvent } from 'react';
import React from 'react';

Expand Down Expand Up @@ -27,6 +28,10 @@ const Login = () => {
Sentry.captureException(new Error('Test error'), { extra: { foo: 'bar' }, tags: { source: 'test' } });
}, []);

const checkMixpanel = React.useCallback(() => {
mixpanel.track('Test event', { my_prop: 'foo bar' });
}, []);

const handleTokenChange = React.useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setToken(event.target.value);
}, []);
Expand Down Expand Up @@ -74,6 +79,7 @@ const Login = () => {
</>
) }
<Button colorScheme="red" onClick={ checkSentry }>Check Sentry</Button>
<Button colorScheme="teal" onClick={ checkMixpanel }>Check Mixpanel</Button>
<Flex columnGap={ 2 } alignItems="center">
<Box w="50px" textAlign="center">{ num }</Box>
<Button onClick={ handleNumIncrement } size="sm">add</Button>
Expand Down
4 changes: 4 additions & 0 deletions ui/shared/Page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import useAdblockDetect from 'lib/hooks/useAdblockDetect';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel';
import AppError from 'ui/shared/AppError/AppError';
import AppErrorBlockConsensus from 'ui/shared/AppError/AppErrorBlockConsensus';
import AppErrorInvalidTxHash from 'ui/shared/AppError/AppErrorInvalidTxHash';
Expand Down Expand Up @@ -32,6 +33,9 @@ const Page = ({

useAdblockDetect();

const isMixpanelInited = mixpanel.useInit();
mixpanel.useLogPageView(isMixpanelInited);

const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorCauseStatusCode(error) || 500;
const resourceErrorPayload = getResourceErrorPayload(error);
Expand Down
Loading

0 comments on commit 7573a30

Please sign in to comment.