diff --git a/projects/Mallard/ios/Podfile.lock b/projects/Mallard/ios/Podfile.lock index 106bbf839..795a74592 100644 --- a/projects/Mallard/ios/Podfile.lock +++ b/projects/Mallard/ios/Podfile.lock @@ -489,6 +489,8 @@ PODS: - React-Core - react-native-geolocation (3.1.0): - React-Core + - react-native-get-random-values (1.11.0): + - React-Core - react-native-in-app-utils (6.1.0): - React - react-native-netinfo (11.3.0): @@ -731,6 +733,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - react-native-config (from `../node_modules/react-native-config`) - "react-native-geolocation (from `../node_modules/@react-native-community/geolocation`)" + - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-in-app-utils (from `../node_modules/react-native-in-app-utils`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pager-view (from `../node_modules/react-native-pager-view`) @@ -861,6 +864,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-config" react-native-geolocation: :path: "../node_modules/@react-native-community/geolocation" + react-native-get-random-values: + :path: "../node_modules/react-native-get-random-values" react-native-in-app-utils: :path: "../node_modules/react-native-in-app-utils" react-native-netinfo: @@ -1012,6 +1017,7 @@ SPEC CHECKSUMS: React-logger: 9fd8d34baa7930b42a70669ec0f0971083ae5a7b react-native-config: 86038147314e2e6d10ea9972022aa171e6b1d4d8 react-native-geolocation: ef66fb798d96284c6043f0b16c15d9d1d4955db4 + react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06 react-native-in-app-utils: 96cdefc90ad74a79a95d19239a183995a6face0b react-native-netinfo: 299dad906cdbf3b67bcc6f693c807f98bdd127cc react-native-pager-view: da490aa1f902c9a5aeecf0909cc975ad0e92e53e diff --git a/projects/Mallard/package.json b/projects/Mallard/package.json index b019ca042..06219bc3f 100644 --- a/projects/Mallard/package.json +++ b/projects/Mallard/package.json @@ -67,6 +67,7 @@ "jetifier": "^1.6.3", "moment": "^2.29.2", "moment-timezone": "^0.5.27", + "nanoid": "^5.0.5", "node-fetch": "^2.6.7", "react": "18.2.0", "react-native": "0.72.11", @@ -77,6 +78,7 @@ "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.12.0", + "react-native-get-random-values": "^1.10.0", "react-native-iap": "^12.13.0", "react-native-image-zoom-viewer": "^3.0.1", "react-native-in-app-utils": "^6.0.2", diff --git a/projects/Mallard/src/App.tsx b/projects/Mallard/src/App.tsx index 5e25ff3b7..9b59e1d9a 100644 --- a/projects/Mallard/src/App.tsx +++ b/projects/Mallard/src/App.tsx @@ -57,15 +57,11 @@ const WithProviders = nestProviders( ); const handleIdStatus = (attempt: AnyAttempt) => - logUserId( - isValid(attempt) ? attempt.data.userDetails.id : null, - 'identity', - ); + logUserId(isValid(attempt) ? attempt.data.userDetails.id : null); const handleOktaStatus = (attempt: AnyAttempt) => logUserId( isValid(attempt) ? attempt.data.userDetails.legacy_identity_id : null, - 'okta', ); const App = () => { diff --git a/projects/Mallard/src/components/front/items/helpers/item-tappable.tsx b/projects/Mallard/src/components/front/items/helpers/item-tappable.tsx index 6bdf0b39b..f4369bbff 100644 --- a/projects/Mallard/src/components/front/items/helpers/item-tappable.tsx +++ b/projects/Mallard/src/components/front/items/helpers/item-tappable.tsx @@ -5,6 +5,7 @@ import React from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, TouchableHighlight, View } from 'react-native'; import type { CAPIArticle, Issue, ItemSizes } from 'src/common'; +import { logPageView } from 'src/helpers/analytics'; import type { MainStackParamList } from 'src/navigation/NavigationModels'; import { RouteNames } from 'src/navigation/NavigationModels'; import type { PathToArticle } from 'src/paths'; @@ -56,6 +57,7 @@ const ItemTappable = ({ const navigation = useNavigation>(); const handlePress = () => { + article?.webUrl && logPageView(article.webUrl); article.type === 'crossword' ? navigation.navigate(RouteNames.Crossword, { path, diff --git a/projects/Mallard/src/helpers/analytics/__tests__/index.spec.ts b/projects/Mallard/src/helpers/analytics/__tests__/index.spec.ts index 79ad30f8e..8f0d85b2f 100644 --- a/projects/Mallard/src/helpers/analytics/__tests__/index.spec.ts +++ b/projects/Mallard/src/helpers/analytics/__tests__/index.spec.ts @@ -1,46 +1,43 @@ import { logEvent, logPageView, logScreenView, logUserId } from '..'; - -const mockLogEvent = jest.fn(); -const mockLogScreenView = jest.fn(); -const mockSetUserId = jest.fn(); -const mockSetUserProperty = jest.fn(); - -jest.mock('@react-native-firebase/analytics', () => - jest.fn().mockImplementation(() => ({ - logEvent: mockLogEvent, - logScreenView: mockLogScreenView, - setUserId: mockSetUserId, - setUserProperty: mockSetUserProperty, - })), -); +import * as Ophan from 'src/services/ophan'; describe('analytics', () => { describe('logEvent', () => { - it('should call "logEvent" from firebase analytics', async () => { + it('should call "sendComponentEvent" from the Ophan service', async () => { + const mockSendComponentEvent = jest + .spyOn(Ophan, 'sendComponentEvent') + .mockResolvedValue(true); await logEvent({ name: 'testComponent', value: 'testing' }); - expect(mockLogEvent).toHaveBeenCalled(); + expect(mockSendComponentEvent).toHaveBeenCalled(); }); }); describe('logPageView', () => { - it('should call "logEvent" from firebase analyticse', async () => { - await logPageView('test/path/to/article'); - expect(mockLogEvent).toHaveBeenCalled(); + it('should call "sendPageViewEvent" from the Ophan service', async () => { + const mockSendPageViewEvent = jest + .spyOn(Ophan, 'sendPageViewEvent') + .mockResolvedValue(true); + await logPageView( + 'http://www.theguardian.com/test/path/to/article', + ); + expect(mockSendPageViewEvent).toHaveBeenCalled(); }); }); describe('logScreenView', () => { - it('should call "logScreenView" from firebase analyticse', async () => { + it('should call "logScreenView" from the Ophan service', async () => { + const mockAppScreenEvent = jest + .spyOn(Ophan, 'sendAppScreenEvent') + .mockResolvedValue(true); await logScreenView('IssueScreen'); - expect(mockLogEvent).toHaveBeenCalled(); + expect(mockAppScreenEvent).toHaveBeenCalled(); }); }); describe('logUserId', () => { - it('should call "setUserId" from firebase analyticse', async () => { - await logUserId('12345', 'identity'); + it('should call "setUserId" from the Ophan service', async () => { + const mockSetUserId = jest + .spyOn(Ophan, 'setUserId') + .mockResolvedValue(true); + await logUserId('12345'); expect(mockSetUserId).toHaveBeenCalled(); - expect(mockSetUserProperty).toHaveBeenCalledWith( - 'authType', - 'identity', - ); }); }); }); diff --git a/projects/Mallard/src/helpers/analytics/index.ts b/projects/Mallard/src/helpers/analytics/index.ts index b26a804dd..df91eaa2c 100644 --- a/projects/Mallard/src/helpers/analytics/index.ts +++ b/projects/Mallard/src/helpers/analytics/index.ts @@ -1,21 +1,19 @@ -import analytics from '@react-native-firebase/analytics'; +import { + Action, + ComponentType, + sendAppScreenEvent, + sendComponentEvent, + sendPageViewEvent, + setUserId, +} from 'src/services/ophan'; import type { AnalyticsEvent, AnalyticsUserId } from './types'; -const toggleAnalyticsRecording = async (enable: boolean): Promise => { - try { - await analytics().setAnalyticsCollectionEnabled(enable); - return true; - } catch { - return false; - } -}; - -const logScreenView = async (routeName: string): Promise => { +const logScreenView = async ( + routeName: string, + value?: string, +): Promise => { try { - await analytics().logScreenView({ - screen_name: routeName, - screen_class: routeName, - }); + await sendAppScreenEvent({ screenName: routeName, value }); return true; } catch { return false; @@ -24,7 +22,12 @@ const logScreenView = async (routeName: string): Promise => { const logEvent = async ({ name, value }: AnalyticsEvent): Promise => { try { - await analytics().logEvent(name, { value }); + await sendComponentEvent({ + componentType: ComponentType.AppButton, + action: Action.Click, + value, + componentId: name, + }); return true; } catch { return false; @@ -32,32 +35,22 @@ const logEvent = async ({ name, value }: AnalyticsEvent): Promise => { }; // This differs from a screen view as this is a "screen" that has significance to journalism e.g. Article screen view -const logPageView = async (path: string): Promise => { +const logPageView = async (url: string): Promise => { try { - await analytics().logEvent('pageView', { path }); + await sendPageViewEvent({ url }); return true; } catch { return false; } }; -const logUserId = async ( - userId: AnalyticsUserId, - authType: 'identity' | 'okta', -): Promise => { +const logUserId = async (userId: AnalyticsUserId): Promise => { try { - await analytics().setUserId(userId); - await analytics().setUserProperty('authType', authType); + await setUserId(userId); return true; } catch { return false; } }; -export { - logEvent, - logPageView, - logScreenView, - logUserId, - toggleAnalyticsRecording, -}; +export { logEvent, logPageView, logScreenView, logUserId }; diff --git a/projects/Mallard/src/hooks/use-gdpr.tsx b/projects/Mallard/src/hooks/use-gdpr.tsx index 7b59708f6..e5bc8e268 100644 --- a/projects/Mallard/src/hooks/use-gdpr.tsx +++ b/projects/Mallard/src/hooks/use-gdpr.tsx @@ -1,5 +1,4 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; -import { toggleAnalyticsRecording } from 'src/helpers/analytics'; import { gdprAllowFunctionalityCache, gdprAllowPerformanceCache, @@ -20,8 +19,9 @@ import { errorService } from 'src/services/errors'; * v8 - Add Firebase Analytics as PERFORMANCE * v9 - Remove additional Logging * v10 - Remove Sentry from the app + * v11 - Enable Ophan as essential */ -const CURRENT_CONSENT_VERSION = 10; +const CURRENT_CONSENT_VERSION = 11; /* Consent switches can be 'unset' or null @@ -117,9 +117,6 @@ export const GDPRProvider = ({ children }: { children: React.ReactNode }) => { // Local state modifier setGdprAllowPerformance(setting); setGdprConsentVersion(CURRENT_CONSENT_VERSION); - setting - ? toggleAnalyticsRecording(setting) - : toggleAnalyticsRecording(false); // Persisted state modifier setting === null ? gdprAllowPerformanceCache.reset() diff --git a/projects/Mallard/src/screens/article/slider/index.tsx b/projects/Mallard/src/screens/article/slider/index.tsx index 91fbcc9a4..e383c8e96 100644 --- a/projects/Mallard/src/screens/article/slider/index.tsx +++ b/projects/Mallard/src/screens/article/slider/index.tsx @@ -227,7 +227,9 @@ const ArticleSlider = React.memo( showsVerticalScrollIndicator={false} scrollEventThrottle={1} onMomentumScrollEnd={() => { - logPageView(flattenedArticles[current].article); + logPageView( + `https://www.theguardian.com/${flattenedArticles[current].article}`, + ); }} onScroll={Animated.event( [ diff --git a/projects/Mallard/src/screens/issue-screen.tsx b/projects/Mallard/src/screens/issue-screen.tsx index 40d7ffa3c..b995419b5 100644 --- a/projects/Mallard/src/screens/issue-screen.tsx +++ b/projects/Mallard/src/screens/issue-screen.tsx @@ -358,7 +358,8 @@ const IssueScreenWithPath = ({ const { initialFrontKey } = useIssueSummary(); useEffect(() => { - issue && logPageView(`editions/${issue.key}`); + issue && + logPageView(`https://www.theguardian.com/editions/${issue.key}`); }, [issue?.key]); return ( diff --git a/projects/Mallard/src/screens/settings/gdpr-consent-screen.tsx b/projects/Mallard/src/screens/settings/gdpr-consent-screen.tsx index a97afec70..0a09a55f8 100644 --- a/projects/Mallard/src/screens/settings/gdpr-consent-screen.tsx +++ b/projects/Mallard/src/screens/settings/gdpr-consent-screen.tsx @@ -34,7 +34,7 @@ type EssentialGdprSwitch = Omit; const essentials: EssentialGdprSwitch = { name: 'Essential', services: - 'YouTube Player - Firebase Cloud Messaging - Firebase Remote Config', + 'Ophan - YouTube Player - Firebase Cloud Messaging - Firebase Remote Config', description: 'These are essential to provide you with services that you have requested. These services support the ability for you to watch videos, see service-related messages, download content automatically and receive new features without app releases.', }; diff --git a/projects/Mallard/src/services/ophan.ts b/projects/Mallard/src/services/ophan.ts new file mode 100644 index 000000000..70ba0fcf9 --- /dev/null +++ b/projects/Mallard/src/services/ophan.ts @@ -0,0 +1,105 @@ +import 'react-native-get-random-values'; +import { customAlphabet } from 'nanoid'; +import type { AnalyticsUserId } from 'src/helpers/analytics/types'; + +enum ComponentType { + AppButton = 'APP_BUTTON', + AppVideo = 'APP_VIDEO', +} + +enum Action { + Click = 'CLICK', + View = 'VIEW', +} + +interface TrackScreen { + screenName: string; + value?: string; +} + +interface TrackComponentEvent { + componentType: ComponentType; + action: Action; + value?: string; + componentId?: string; +} + +const ophan = async (urlParams: string): Promise => { + const viewIdString = customAlphabet('qwertyuiopasdfghjklzxcvbnm0123456789'); + const viewId = `viewId=${viewIdString(17).toLowerCase()}`; + const url = `https://ophan.theguardian.com/img/1?v=17&platfom=editions&${viewId}&${urlParams}`; + const response = await fetch(url, { method: 'GET' }); + const { status } = response; + return status === 204; +}; + +const setUserId = async (userId: AnalyticsUserId) => { + try { + if (!userId) return null; + return await ophan(`userId=${userId}`); + } catch { + return null; + } +}; + +const sendAppScreenEvent = async ({ + screenName, + value, +}: TrackScreen): Promise => { + try { + const componentEvent = { + component: { + component: 'APP_SCREEN', + action: 'VIEW', + id: screenName, + value, + }, + }; + const urlParams = encodeURIComponent(JSON.stringify(componentEvent)); + return await ophan(urlParams); + } catch { + return false; + } +}; + +const sendComponentEvent = async ({ + componentType, + action, + value, + componentId, +}: TrackComponentEvent): Promise => { + try { + const componentEvent = { + component: { + component: componentType, + action, + id: componentId, + value, + }, + }; + const urlParams = encodeURIComponent(JSON.stringify(componentEvent)); + return await ophan(urlParams); + } catch { + return false; + } +}; + +const sendPageViewEvent = async ({ url }: { url: string }) => { + try { + const stringPath = encodeURIComponent(url); + return await ophan( + `url=${stringPath}&ref=https%3A%2F%2Fwww.theguardian.com%2Fuk&visibilityState=visible&tz=0&navigationType=reload`, + ); + } catch (e) { + return false; + } +}; + +export { + Action, + ComponentType, + sendAppScreenEvent, + sendComponentEvent, + sendPageViewEvent, + setUserId, +}; diff --git a/projects/Mallard/yarn.lock b/projects/Mallard/yarn.lock index 605f9b75c..f77bf608b 100644 --- a/projects/Mallard/yarn.lock +++ b/projects/Mallard/yarn.lock @@ -4331,6 +4331,11 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +fast-base64-decode@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" + integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6381,6 +6386,11 @@ nanoid@^3.1.23, nanoid@^3.3.1: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== +nanoid@^5.0.5: + version "5.0.6" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.6.tgz#7f99a033aa843e4dcf9778bdaec5eb02f4dc44d5" + integrity sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA== + natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -7053,6 +7063,13 @@ react-native-gesture-handler@2.12.0: lodash "^4.17.21" prop-types "^15.7.2" +react-native-get-random-values@^1.10.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d" + integrity sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ== + dependencies: + fast-base64-decode "^1.0.0" + react-native-iap@^12.13.0: version "12.13.0" resolved "https://registry.yarnpkg.com/react-native-iap/-/react-native-iap-12.13.0.tgz#8df99c616db5ba14bf34cf3f45e8730c3bcd2a7e"