Commit ca91333d authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #1489 from blockscout/tom2drum/issue-1484

GrowthBook integration
parents 8f6e3e2a e6974331
......@@ -4,5 +4,6 @@ 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_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
FAVICON_GENERATOR_API_KEY=xxx
\ No newline at end of file
......@@ -9,7 +9,7 @@ const baseUrl = [
appHost,
appPort && ':' + appPort,
].filter(Boolean).join('');
const isDev = getEnvValue('NODE_ENV') === 'development';
const isDev = getEnvValue('NEXT_PUBLIC_APP_ENV') === 'development';
const app = Object.freeze({
isDev,
......
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const clientKey = getEnvValue('NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY');
const title = 'GrowthBook feature flagging and A/B testing';
const config: Feature<{ clientKey: string }> = (() => {
if (clientKey) {
return Object.freeze({
title,
isEnabled: true,
clientKey,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
......@@ -8,6 +8,7 @@ export { default as blockchainInteraction } from './blockchainInteraction';
export { default as csvExport } from './csvExport';
export { default as googleAnalytics } from './googleAnalytics';
export { default as graphqlApiDocs } from './graphqlApiDocs';
export { default as growthBook } from './growthBook';
export { default as marketplace } from './marketplace';
export { default as mixpanel } from './mixpanel';
export { default as nameService } from './nameService';
......
......@@ -452,6 +452,7 @@ const schema = yup
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(),
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(),
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: yup.string(),
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(),
// Misc
NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(),
......
......@@ -73,9 +73,6 @@ frontend:
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']"
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
OTEL_SDK_ENABLED: true
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger-collector.jaeger.svc.cluster.local:4318
NEXT_OTEL_VERBOSE: 1
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......@@ -84,3 +81,5 @@ frontend:
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
\ No newline at end of file
......@@ -38,6 +38,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Export data to CSV file](ENVS.md#export-data-to-csv-file)
- [Google analytics](ENVS.md#google-analytics)
- [Mixpanel analytics](ENVS.md#mixpanel-analytics)
- [GrowthBook feature flagging and A/B testing](ENVS.md#growthbook-feature-flagging-and-ab-testing)
- [GraphQL API documentation](ENVS.md#graphql-api-documentation)
- [REST API documentation](ENVS.md#rest-api-documentation)
- [Marketplace](ENVS.md#marketplace)
......@@ -387,6 +388,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
&nbsp;
### GrowthBook feature flagging and A/B testing
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY | `string` | Client SDK key for [GrowthBook](https://www.growthbook.io/) service | true | - | `<your-secret>` |
&nbsp;
### GraphQL API documentation
This feature is **always enabled**, but you can configure its behavior by passing the following variables.
......
import { ChakraProvider } from '@chakra-ui/react';
import { GrowthBookProvider } from '@growthbook/growthbook-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react';
......@@ -37,9 +38,11 @@ const TestApp = ({ children }: {children: React.ReactNode}) => {
<QueryClientProvider client={ queryClient }>
<AppContextProvider pageProps={ PAGE_PROPS }>
<ScrollDirectionProvider>
<GrowthBookProvider>
<SocketProvider>
{ children }
</SocketProvider>
</GrowthBookProvider>
</ScrollDirectionProvider>
</AppContextProvider>
</QueryClientProvider>
......
......@@ -13,7 +13,8 @@ export enum NAMES {
INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug',
ADDRESS_NFT_DISPLAY_TYPE='address_nft_display_type'
ADDRESS_NFT_DISPLAY_TYPE='address_nft_display_type',
UUID='uuid',
}
export function get(name?: NAMES | undefined | null, serverCookie?: string) {
......
export const STORAGE_KEY = 'growthbook:experiments';
export const STORAGE_LIMIT = 20;
import { GrowthBook } from '@growthbook/growthbook-react';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel';
import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
}
export const growthBook = (() => {
const feature = config.features.growthBook;
if (!feature.isEnabled) {
return;
}
return new GrowthBook<GrowthBookFeatures>({
apiHost: 'https://cdn.growthbook.io',
clientKey: feature.clientKey,
enableDevMode: config.app.isDev,
attributes: {
id: mixpanel.getUuid(),
chain_id: config.chain.id,
},
trackingCallback: (experiment, result) => {
if (isExperimentStarted(experiment.key)) {
return;
}
saveExperimentInStorage(experiment.key);
mixpanel.logEvent(mixpanel.EventTypes.EXPERIMENT_STARTED, {
'Experiment name': experiment.key,
'Variant name': result.value,
Source: 'growthbook',
});
},
});
})();
function getStorageValue(): Array<unknown> | undefined {
const item = window.localStorage.getItem(STORAGE_KEY);
if (!item) {
return;
}
try {
const parsedValue = JSON.parse(item);
if (Array.isArray(parsedValue)) {
return parsedValue;
}
} catch {
return;
}
}
function isExperimentStarted(key: string): boolean {
const items = getStorageValue() ?? [];
return items.some((item) => item === key);
}
function saveExperimentInStorage(key: string) {
const items = getStorageValue() ?? [];
const newItems = [ key, ...items ].slice(0, STORAGE_LIMIT);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(newItems));
} catch (error) {}
}
import type { WidenPrimitives } from '@growthbook/growthbook';
import { useFeatureValue, useGrowthBook } from '@growthbook/growthbook-react';
import type { GrowthBookFeatures } from './init';
export default function useGbFeatureValue<Name extends keyof GrowthBookFeatures>(
name: Name,
fallback: GrowthBookFeatures[Name],
): { value: WidenPrimitives<GrowthBookFeatures[Name]>; isLoading: boolean } {
const value = useFeatureValue(name, fallback);
const growthBook = useGrowthBook();
return { value, isLoading: !(growthBook?.ready ?? true) };
}
import React from 'react';
import { SECOND } from 'lib/consts';
import { growthBook } from './init';
export default function useLoadFeatures() {
React.useEffect(() => {
growthBook?.setAttributes({
...growthBook.getAttributes(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: window.navigator.language,
});
growthBook?.loadFeatures({ timeout: SECOND });
}, []);
}
export default function getGoogleAnalyticsClientId() {
return window.ga?.getAll()[0].get('clientId');
}
import * as cookies from 'lib/cookies';
import * as growthBook from 'lib/growthbook/consts';
import isBrowser from 'lib/isBrowser';
export default function getUuid() {
const cookie = cookies.get(cookies.NAMES.UUID);
if (cookie) {
return cookie;
}
const uuid = crypto.randomUUID();
cookies.set(cookies.NAMES.UUID, uuid);
if (isBrowser()) {
window.localStorage.removeItem(growthBook.STORAGE_KEY);
}
return uuid;
}
import getPageType from './getPageType';
import getUuid from './getUuid';
import logEvent from './logEvent';
import useInit from './useInit';
import useLogPageView from './useLogPageView';
......@@ -9,4 +10,5 @@ export {
useLogPageView,
logEvent,
getPageType,
getUuid,
};
import config from 'configs/app';
import delay from 'lib/delay';
export default function isGoogleAnalyticsLoaded(retries = 3): Promise<boolean> {
if (!retries || !config.features.googleAnalytics.isEnabled) {
return Promise.resolve(false);
}
return typeof window.ga?.getAll === 'function' ? Promise.resolve(true) : delay(500).then(() => isGoogleAnalyticsLoaded(retries - 1));
}
......@@ -9,8 +9,7 @@ import config from 'configs/app';
import * as cookies from 'lib/cookies';
import getQueryParamString from 'lib/router/getQueryParamString';
import getGoogleAnalyticsClientId from './getGoogleAnalyticsClientId';
import isGoogleAnalyticsLoaded from './isGoogleAnalyticsLoaded';
import getUuid from './getUuid';
export default function useMixpanelInit() {
const [ isInited, setIsInited ] = React.useState(false);
......@@ -18,7 +17,6 @@ export default function useMixpanelInit() {
const debugFlagQuery = React.useRef(getQueryParamString(router.query._mixpanel_debug));
React.useEffect(() => {
isGoogleAnalyticsLoaded().then((isGALoaded) => {
const feature = config.features.mixpanel;
if (!feature.isEnabled) {
return;
......@@ -39,15 +37,14 @@ export default function useMixpanelInit() {
'Viewport width': window.innerWidth,
'Viewport height': window.innerHeight,
Language: window.navigator.language,
'User id': isGALoaded ? getGoogleAnalyticsClientId() : undefined,
'Device type': _capitalize(deviceType),
'User id': getUuid(),
});
setIsInited(true);
if (debugFlagQuery.current && !debugFlagCookie) {
cookies.set(cookies.NAMES.MIXPANEL_DEBUG, 'true');
}
});
}, []);
return isInited;
......
......@@ -16,6 +16,7 @@ export enum EventTypes {
QR_CODE = 'QR code',
PAGE_WIDGET = 'Page widget',
TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction',
EXPERIMENT_STARTED = 'Experiment started',
FILTERS = 'Filters'
}
......@@ -97,6 +98,11 @@ Type extends EventTypes.PAGE_WIDGET ? (
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
'Type': 'Address click' | 'Token click';
} :
Type extends EventTypes.EXPERIMENT_STARTED ? {
'Experiment name': string;
'Variant name': string;
'Source': 'growthbook';
} :
Type extends EventTypes.FILTERS ? {
'Source': 'Marketplace';
'Filter name': string;
......
......@@ -9,6 +9,7 @@ function generateCspPolicy() {
descriptors.googleAnalytics(),
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.growthBook(),
descriptors.mixpanel(),
descriptors.monaco(),
descriptors.safe(),
......
import type CspDev from 'csp-dev';
import config from 'configs/app';
export function growthBook(): CspDev.DirectiveDescriptor {
if (!config.features.growthBook.isEnabled) {
return {};
}
return {
'connect-src': [
'cdn.growthbook.io',
],
};
}
......@@ -4,6 +4,7 @@ export { cloudFlare } from './cloudFlare';
export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
export { safe } from './safe';
......
import type { ChakraProps } from '@chakra-ui/react';
import { GrowthBookProvider } from '@growthbook/growthbook-react';
import * as Sentry from '@sentry/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
......@@ -12,6 +13,8 @@ import useQueryClientConfig from 'lib/api/useQueryClientConfig';
import { AppContextProvider } from 'lib/contexts/app';
import { ChakraProvider } from 'lib/contexts/chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import { growthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import { SocketProvider } from 'lib/socket/context';
import theme from 'theme';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
......@@ -39,6 +42,8 @@ const ERROR_SCREEN_STYLES: ChakraProps = {
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
useLoadFeatures();
const queryClient = useQueryClientConfig();
const handleError = React.useCallback((error: Error) => {
......@@ -56,11 +61,13 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<Web3ModalProvider>
<AppContextProvider pageProps={ pageProps }>
<QueryClientProvider client={ queryClient }>
<GrowthBookProvider growthbook={ growthBook }>
<ScrollDirectionProvider>
<SocketProvider url={ `${ config.api.socket }${ config.api.basePath }/socket/v2` }>
{ getLayout(<Component { ...pageProps }/>) }
</SocketProvider>
</ScrollDirectionProvider>
</GrowthBookProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
<GoogleAnalytics/>
</QueryClientProvider>
......
import { ChakraProvider } from '@chakra-ui/react';
import { GrowthBookProvider } from '@growthbook/growthbook-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createWeb3Modal, defaultWagmiConfig } from '@web3modal/wagmi/react';
import React from 'react';
......@@ -64,9 +65,11 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<QueryClientProvider client={ queryClient }>
<SocketProvider url={ withSocket ? `ws://${ app.domain }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiConfig config={ wagmiConfig }>
{ children }
</WagmiConfig>
</GrowthBookProvider>
</AppContextProvider>
</SocketProvider>
</QueryClientProvider>
......
......@@ -6,6 +6,7 @@ import React from 'react';
import config from 'configs/app';
import * as cookies from 'lib/cookies';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useToast from 'lib/hooks/useToast';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -15,6 +16,8 @@ const Login = () => {
const toast = useToast();
const [ num, setNum ] = useGradualIncrement(0);
const testFeature = useFeatureValue('test_value', 'fallback');
const [ isFormVisible, setFormVisibility ] = React.useState(false);
const [ token, setToken ] = React.useState('');
......@@ -77,12 +80,15 @@ const Login = () => {
<Button onClick={ handleSetTokenClick }>Set cookie</Button>
</>
) }
<Flex columnGap={ 2 }>
<Button colorScheme="red" onClick={ checkSentry }>Check Sentry</Button>
<Button colorScheme="teal" onClick={ checkMixpanel }>Check Mixpanel</Button>
</Flex>
<Flex columnGap={ 2 } alignItems="center">
<Box w="50px" textAlign="center">{ num }</Box>
<Button onClick={ handleNumIncrement } size="sm">add</Button>
</Flex>
<Box>Test feature value: <b>{ testFeature.isLoading ? 'loading...' : JSON.stringify(testFeature.value) }</b></Box>
</VStack>
);
......
......@@ -2859,6 +2859,20 @@
"@n1ru4l/push-pull-async-iterable-iterator" "^3.1.0"
meros "^1.1.4"
"@growthbook/growthbook-react@0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@growthbook/growthbook-react/-/growthbook-react-0.21.0.tgz#e3aee1bda7faf3ea036badfbca09e2230884b7f7"
integrity sha512-LM4jG9xThSYCMfRQECeLtTr8sYyiAxC5NLmT4U3uxKyvVUCB+s0k/vdKrvHcv3YXNJ37mW3VtK9XkNSuW16mcw==
dependencies:
"@growthbook/growthbook" "^0.31.0"
"@growthbook/growthbook@^0.31.0":
version "0.31.0"
resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-0.31.0.tgz#66273344805c45dc406940ab719856385afa4750"
integrity sha512-VtI5IibZHhvtrlZhfopGYhVXZ++FewgY0BtQQyEqFSrhCYFvEEtq+GkuSm4GcC0JxNuHiYJr9/R3GpffJV/T4Q==
dependencies:
dom-mutator "^0.6.0"
"@grpc/grpc-js@^1.7.1":
version "1.9.9"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.9.tgz#ce3a05439b1c957ec64c2ecdc6f1e4f54e8af797"
......@@ -9314,6 +9328,11 @@ dom-helpers@^5.0.1:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-mutator@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5"
integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==
dom-serializer@^1.0.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment