From c005e0271301afa9eccd43cef41d50251586c630 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 19 Jan 2026 17:32:59 -0600 Subject: [PATCH] WIP --- .env.example | 4 + package.json | 1 + src/App.native.tsx | 6 +- src/App.web.tsx | 8 +- src/env/common.ts | 11 ++ src/logger/growthbook/context.tsx | 114 +++++++++++++++++++++ src/logger/growthbook/util/referrer.ts | 2 + src/logger/growthbook/util/referrer.web.ts | 3 + src/logger/{index.ts => index.tsx} | 8 ++ yarn.lock | 19 ++++ 10 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 src/logger/growthbook/context.tsx create mode 100644 src/logger/growthbook/util/referrer.ts create mode 100644 src/logger/growthbook/util/referrer.web.ts rename src/logger/{index.ts => index.tsx} (94%) diff --git a/.env.example b/.env.example index e90541cabb..65fb601353 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ EXPO_PUBLIC_CHAT_PROXY_DID= # # +# Growthbook config +EXPO_PUBLIC_GROWTHBOOK_API_HOST= +EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY= + # Sentry DSN for telemetry EXPO_PUBLIC_SENTRY_DSN= diff --git a/package.json b/package.json index b89ec45d6f..fc9d5ed714 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", + "@growthbook/growthbook-react": "^1.6.2", "@haileyok/bluesky-video": "0.3.2", "@ipld/dag-cbor": "^9.2.0", "@lingui/react": "^4.14.1", diff --git a/src/App.native.tsx b/src/App.native.tsx index 7f49c0a145..fb93534435 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -21,7 +21,7 @@ import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' -import {logger} from '#/logger' +import {logger, Provider as LoggingProvider} from '#/logger' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' @@ -238,7 +238,9 @@ function App() { - + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 460d9ff174..2eae93d3e2 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -12,7 +12,8 @@ import {QueryProvider} from '#/lib/react-query' import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {ThemeProvider} from '#/lib/ThemeContext' import I18nProvider from '#/locale/i18nProvider' -import {logger} from '#/logger' +import {logger, Provider as LoggingProvider} from '#/logger' +import {initializer as growthbookInitializer} from '#/logger/growthbook/context' import {Provider as A11yProvider} from '#/state/a11y' import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' import {Provider as DialogStateProvider} from '#/state/dialogs' @@ -85,6 +86,7 @@ function InnerApp() { useEffect(() => { async function onLaunch(account?: SessionAccount) { try { + await growthbookInitializer if (account) { await resumeSession(account) } @@ -205,7 +207,9 @@ function App() { - + + + diff --git a/src/env/common.ts b/src/env/common.ts index 04e98c49cb..0cb8d67872 100644 --- a/src/env/common.ts +++ b/src/env/common.ts @@ -84,6 +84,17 @@ export const BLUESKY_PROXY_DID: Did = export const CHAT_PROXY_DID: Did = process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat' +/** + * Growthbook API host + */ +export const GROWTHBOOK_API_HOST: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST + +/** + * Growthbook client key + */ +export const GROWTHBOOK_CLIENT_KEY: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY + + /** * Sentry DSN for telemetry */ diff --git a/src/logger/growthbook/context.tsx b/src/logger/growthbook/context.tsx new file mode 100644 index 0000000000..da45e886e3 --- /dev/null +++ b/src/logger/growthbook/context.tsx @@ -0,0 +1,114 @@ +import {useMemo, useEffect} from 'react' +import {Platform} from 'react-native' +import { + GrowthBook, + GrowthBookProvider, + useFeatureIsOn, +} from '@growthbook/growthbook-react' + +import * as env from '#/env' +import {useSession, SessionAccount} from '#/state/session' +import {useGeolocation} from '#/geolocation' +import * as persisted from '#/state/persisted' +import * as referrer from '#/logger/growthbook/util/referrer' + +type DefaultAttributes = { + country: string +} +type UserAttributes = { + id: string + pds: string | undefined + platform: string + appVersion: string + bundleIdentifier: string + bundleDate: number + refSrc: string + refUrl: string + appLanguage: string + contentLanguages: string[] +} + +const gb = new GrowthBook({ + apiHost: env.GROWTHBOOK_API_HOST, + clientKey: env.GROWTHBOOK_CLIENT_KEY, + trackingCallback: (experiment, result) => { + // TODO + console.log('Experiment Viewed', { + experimentId: experiment.key, + variationId: result.key, + }) + }, +}) + +export const initializer = gb.init({ + timeout: 1e3, +}) + +export function useGate(gate: string): boolean { + return useFeatureIsOn(gate) +} + +export function Provider({children}: {children: React.ReactNode}) { + const geo = useGeolocation() + const {currentAccount} = useSession() + const defaultAttributes = useMemo( + () => ({ + country: geo.countryCode || 'unknown', + }), + [geo], + ) + + /** + * Decorate existing attributes with any new default attributes + */ + useEffect(() => { + const attr = { + ...gb.getAttributes(), + ...defaultAttributes, + } + gb.setAttributes(attr) + console.debug(`update attributes`, {attributes: attr}) + }, [defaultAttributes]) + + /** + * Update user attributes on session change, and clear them on logout + */ + useEffect(() => { + if (currentAccount) { + const attr = getUserAttributes(currentAccount) + gb.setAttributes({ + ...defaultAttributes, + ...getUserAttributes(currentAccount), + }) + console.debug(`has session, set attributes`, {attributes: attr}) + } else { + gb.setAttributes(defaultAttributes) + console.debug(`no session, reset attributes`, { + attributes: defaultAttributes, + }) + } + }, [defaultAttributes, currentAccount]) + + return {children} +} + +export function getUserAttributes(account: SessionAccount): UserAttributes +export function getUserAttributes(account: undefined): null +export function getUserAttributes( + account?: SessionAccount, +): UserAttributes | null { + if (!account) return null + const languagePrefs = persisted.get('languagePrefs') + return { + id: account.did, + pds: account.pdsUrl, + platform: Platform.OS, + appVersion: env.RELEASE_VERSION, + bundleIdentifier: env.BUNDLE_IDENTIFIER, + bundleDate: env.BUNDLE_DATE, + appLanguage: languagePrefs.appLanguage, + contentLanguages: languagePrefs.contentLanguages, + refSrc: referrer.src, + refUrl: referrer.url, + } +} diff --git a/src/logger/growthbook/util/referrer.ts b/src/logger/growthbook/util/referrer.ts new file mode 100644 index 0000000000..d2ccdc47a4 --- /dev/null +++ b/src/logger/growthbook/util/referrer.ts @@ -0,0 +1,2 @@ +export const src = '' +export const url = '' diff --git a/src/logger/growthbook/util/referrer.web.ts b/src/logger/growthbook/util/referrer.web.ts new file mode 100644 index 0000000000..0105ccd476 --- /dev/null +++ b/src/logger/growthbook/util/referrer.web.ts @@ -0,0 +1,3 @@ +const params = new URLSearchParams(window.location.search) +export const src = params.get('ref_src') ?? '' +export const url = decodeURIComponent(params.get('ref_url') ?? '') diff --git a/src/logger/index.ts b/src/logger/index.tsx similarity index 94% rename from src/logger/index.ts rename to src/logger/index.tsx index 3da8908c79..cabb76653e 100644 --- a/src/logger/index.ts +++ b/src/logger/index.tsx @@ -1,5 +1,6 @@ import {nanoid} from 'nanoid/non-secure' +import {Provider as GrowthbookProvider} from '#/logger/growthbook/context' import {logEvent} from '#/lib/statsig/statsig' import {add} from '#/logger/logDump' import {type MetricEvents} from '#/logger/metrics' @@ -171,3 +172,10 @@ export class Logger { * `logger.error(error[, metadata])` */ export const logger = Logger.create(Logger.Context.Default) + +/** + * Logger context provider + */ +export function Provider({children}: {children: React.ReactNode}) { + return {children} +} diff --git a/yarn.lock b/yarn.lock index cc81ec14ef..5179c42234 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4678,6 +4678,20 @@ dependencies: nanoid "^3.3.1" +"@growthbook/growthbook-react@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@growthbook/growthbook-react/-/growthbook-react-1.6.2.tgz#847135be0c46b167f980dbe6015e5f4ed010475c" + integrity sha512-96Bo2Jwd4NBn/kBLN4ceF299PhTw8fQltRykD32hu2xMW8/LXhB8swxbPshGK+Xfa2gjgt24kpZ5oSvaVLLT7w== + dependencies: + "@growthbook/growthbook" "^1.6.2" + +"@growthbook/growthbook@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.2.tgz#6a25122deac8a09955f6bddeb134af62b209809b" + integrity sha512-x3sK6Lff4BVusIzcdBeHZqA3B4kLs3kM/pJ7vvLTJwb6N/+Yn99EF1yc0XU6cfDVqFq6uFkvIFhMwDWUaKD73g== + dependencies: + dom-mutator "^0.6.0" + "@grpc/grpc-js@^1.8.20": version "1.13.3" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904" @@ -10522,6 +10536,11 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +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"