Skip to content

Commit

Permalink
Merge pull request #6701 from CitizenLabDotCo/TAN-665/user-recordings
Browse files Browse the repository at this point in the history
TAN-665 Posthog user recordings
  • Loading branch information
IvaKop authored Jan 15, 2024
2 parents 9a5c56f + cb71ac5 commit e518739
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 63 deletions.
12 changes: 12 additions & 0 deletions back/config/schemas/settings.schema.json.erb
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,18 @@
"allowed": { "type": "boolean", "default": false },
"enabled": { "type": "boolean", "default": false }
}
},

"user_session_recording": {
"type": "object",
"title": "User session recording",
"description": "Enables the recording of a small fraction of user sessions for analysis and product research purposes. Requires active consent from the customer.",
"additionalProperties": false,
"required": ["allowed", "enabled"],
"properties": {
"allowed": { "type": "boolean", "default": true },
"enabled": { "type": "boolean", "default": false }
}
}
},
"dependencies": {
Expand Down
4 changes: 4 additions & 0 deletions back/engines/commercial/multi_tenancy/db/seeds/tenants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,10 @@ def create_localhost_tenant
import_printed_forms: {
enabled: true,
allowed: true
},
user_session_recording: {
enabled: true,
allowed: true
}
})
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ namespace :cl2_back do # rubocop:disable Metrics/BlockLength
follow: {
enabled: true,
allowed: true
},
user_session_recording: {
enabled: false,
allowed: false
}
}
)
Expand Down
1 change: 1 addition & 0 deletions front/app/api/app_configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export interface IAppConfigurationSettings {
power_bi?: AppConfigurationFeature;
analysis?: AppConfigurationFeature;
import_printed_forms?: AppConfigurationFeature;
user_session_recording?: AppConfigurationFeature;
}

export type TAppConfigurationSettingCore = keyof IAppConfigurationSettingsCore;
Expand Down
82 changes: 82 additions & 0 deletions front/app/containers/App/UserSessionRecordingModal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState, useEffect } from 'react';

import {
Icon,
colors,
Text,
Button,
Title,
Box,
} from '@citizenlab/cl2-component-library';
import { useIntl } from 'utils/cl-intl';
import messages from './messages';
import Modal from 'components/UI/Modal';

import { get, set } from 'js-cookie';
import eventEmitter from 'utils/eventEmitter';
import useFeatureFlag from 'hooks/useFeatureFlag';

const UserSessionRecordingModal = () => {
const [modalOpened, setModalOpened] = useState(false);
const { formatMessage } = useIntl();
const userSessionRecodingFeatureFlag = useFeatureFlag({
name: 'user_session_recording',
});

useEffect(() => {
const shouldShowModal = () => {
const hasSeenModal = get('user_session_recording_modal');
const show =
userSessionRecodingFeatureFlag &&
hasSeenModal !== 'true' &&
Math.random() < 0.01; // 1% chance of showing the modal
return show;
};

if (shouldShowModal()) {
setModalOpened(true);
}
}, [userSessionRecodingFeatureFlag]);

const onAccept = () => {
setModalOpened(false);
eventEmitter.emit('user_session_recording_accepted', true);
set('user_session_recording_modal', 'true');
};

const onClose = () => {
setModalOpened(false);
set('user_session_recording_modal', 'true');
};

return (
<Modal opened={modalOpened} close={onClose}>
<Box p="24px">
<Box display="flex" gap="16px" alignItems="center">
<Icon
name="alert-circle"
fill={colors.green500}
width="40px"
height="40px"
/>
<Title>{formatMessage(messages.modalTitle)}</Title>
</Box>

<Text fontSize="l">{formatMessage(messages.modalDescription1)}</Text>
<Text fontSize="l">{formatMessage(messages.modalDescription2)}</Text>
<Text fontSize="l">{formatMessage(messages.modalDescription3)}</Text>

<Box display="flex" justifyContent="flex-end" gap="16px" mt="48px">
<Button buttonStyle="secondary" onClick={onClose}>
{formatMessage(messages.reject)}
</Button>
<Button buttonStyle="primary" onClick={onAccept}>
{formatMessage(messages.accept)}
</Button>
</Box>
</Box>
</Modal>
);
};

export default UserSessionRecordingModal;
30 changes: 30 additions & 0 deletions front/app/containers/App/UserSessionRecordingModal/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { defineMessages } from 'react-intl';

export default defineMessages({
modalTitle: {
id: 'app.sessionRecording.modalTitle',
defaultMessage: 'Help us improve this website',
},
modalDescription1: {
id: 'app.sessionRecording.modalDescription1',
defaultMessage:
'In order to better understand our users, we randomly ask a small percentage of visitors to track their browsing session in detail.',
},
modalDescription2: {
id: 'app.sessionRecording.modalDescription2',
defaultMessage:
'The sole purpose of the recorded data is to improve the website. None of your data will be shared with a 3rd party. Any sensitive information you enter will be filtered.',
},
modalDescription3: {
id: 'app.sessionRecording.modalDescription3',
defaultMessage: 'Do you accept?',
},
accept: {
id: 'app.sessionRecording.accept',
defaultMessage: 'Yes, I accept',
},
reject: {
id: 'app.sessionRecording.reject',
defaultMessage: 'No, I reject',
},
});
2 changes: 2 additions & 0 deletions front/app/containers/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { Locale } from 'typings';
import { removeLocale } from 'utils/cl-router/updateLocationDescriptor';
import useAuthUser from 'api/me/useAuthUser';
import { configureScope } from '@sentry/react';
import UserSessionRecordingModal from './UserSessionRecordingModal';

interface Props {
children: React.ReactNode;
Expand Down Expand Up @@ -332,6 +333,7 @@ const App = ({ children }: Props) => {
minHeight="100vh"
>
<Meta />
<UserSessionRecordingModal />
<ErrorBoundary>
<Suspense fallback={null}>
<UserDeletedModal
Expand Down
145 changes: 104 additions & 41 deletions front/app/modules/commercial/posthog_integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
* data for visitors or regular users
*/

import { combineLatest, pairwise, startWith, Subscription } from 'rxjs';
import {
BehaviorSubject,
combineLatest,
pairwise,
startWith,
Subscription,
} from 'rxjs';
import authUserStream from 'api/me/authUserStream';
import { events$, pageChanges$ } from 'utils/analytics';
import { isNilOrError } from 'utils/helperUtils';
Expand All @@ -17,50 +23,51 @@ import { IUser } from 'api/users/types';
import appConfigurationStream from 'api/app_configuration/appConfigurationStream';
import { IAppConfiguration } from 'api/app_configuration/types';
import { getFullName } from 'utils/textUtils';
import eventEmitter, { IEventEmitterEvent } from 'utils/eventEmitter';
import { PostHog } from 'posthog-js';

const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY;

let eventsSubscription: Subscription | null = null;
let pagesSubscription: Subscription | null = null;

/** There seems to be no documented way to check whether posthog is initialized
* already, so we'll just do some manual state management 🤷‍♂️ */
let posthogInitialized = false;

/** Posthog has a large bundle size, so we don't just import it, but only
* when we actually need it */
const lazyLoadedPosthog = async () => {
const ph = await import('posthog-js');
return ph.default;
};

let posthogClient: PostHog | undefined;

const initializePosthog = async (
token: string,
user: IUser,
user: IUser | undefined,
appConfig: IAppConfiguration
) => {
const posthog = await lazyLoadedPosthog();

posthog.init(token, {
api_host: 'https://eu.posthog.com',
disable_session_recording: true,
autocapture: false,
persistence: 'memory', // no cookies
loaded(ph) {
posthogInitialized = true;

if (posthog.has_opted_out_capturing({ enable_persistence: false })) {
posthog.opt_in_capturing({ enable_persistence: false });
}

// This sets the user for all subsequent events, and sets/updates her attributes
ph.identify(user.data.id, {
email: user.data.attributes.email,
name: getFullName(user.data),
first_name: user.data.attributes.first_name,
last_name: user.data.attributes.last_name,
locale: user.data.attributes.locale,
highest_role: user.data.attributes.highest_role,
});
if (user) {
// This sets the user for all subsequent events, and sets/updates her attributes
ph.identify(user.data.id, {
email: user.data.attributes.email,
name: getFullName(user.data),
first_name: user.data.attributes.first_name,
last_name: user.data.attributes.last_name,
locale: user.data.attributes.locale,
highest_role: user.data.attributes.highest_role,
});
}

// These are the groups we're associating the user with
ph.group('tenant', appConfig.data.id, {
Expand Down Expand Up @@ -88,41 +95,97 @@ const initializePosthog = async (
posthog.capture('$pageview');
});
}

return posthog;
};

// This event emitter wrapper is needed because the native eventEmitter observable does not work when inside `combineLatest`
const eventEmitterWrapperStream = new BehaviorSubject<
IEventEmitterEvent<unknown> | undefined
>(undefined);

eventEmitter
.observeEvent('user_session_recording_accepted')
.subscribe((event) => {
eventEmitterWrapperStream.next(event);
});

const configuration: ModuleConfiguration = {
beforeMountApplication: () => {
if (!POSTHOG_API_KEY) return;

combineLatest([
eventEmitterWrapperStream,
appConfigurationStream,
authUserStream.pipe(startWith(null), pairwise()),
]).subscribe(async ([appConfig, [prevUser, user]]) => {
if (appConfig) {
// Check the feature flag
const posthogSettings =
appConfig.data.attributes.settings.posthog_integration;
if (!posthogSettings?.allowed || !posthogSettings.enabled) return;

// In case the user signs in or visits signed in as an admin/moderator
if (!isNilOrError(user) && (isAdmin(user) || !isRegularUser(user))) {
initializePosthog(POSTHOG_API_KEY, user, appConfig);
}

// In case the user signs out
if (prevUser && !user && posthogInitialized) {
const posthog = await lazyLoadedPosthog();
pagesSubscription?.unsubscribe();
eventsSubscription?.unsubscribe();

// There seems to be no way to call opt_out_capturing without posthog
// writing to localstorage. Clearing it, instead, seems to work fine.
posthog.clear_opt_in_out_capturing({ enable_persistence: false });

posthogInitialized = false;
]).subscribe(
async ([userSessionRecordingAccepted, appConfig, [prevUser, user]]) => {
if (appConfig) {
// USERS

// Initialize posthog for users that accepted the session recording
if (userSessionRecordingAccepted?.eventValue === true) {
// Check the feature flag
const userSessionRecordingSettings =
appConfig.data.attributes.settings.user_session_recording;

if (
!userSessionRecordingSettings?.allowed ||
!userSessionRecordingSettings.enabled
) {
return;
}
if (!posthogClient) {
posthogClient = await initializePosthog(
POSTHOG_API_KEY,
user ?? undefined,
appConfig
);
}

posthogClient.startSessionRecording();
}

// ADMINS AND MODERATORS

// Check the feature flag
const posthogSettings =
appConfig.data.attributes.settings.posthog_integration;

if (!posthogSettings?.allowed || !posthogSettings.enabled) return;

// In case the user signs in or visits signed in as an admin/moderator
if (
!posthogClient &&
!isNilOrError(user) &&
(isAdmin(user) || !isRegularUser(user))
) {
posthogClient = await initializePosthog(
POSTHOG_API_KEY,
user,
appConfig
);
}

// In case an admin signs out
if (
prevUser &&
!user &&
posthogClient &&
userSessionRecordingAccepted?.eventValue !== true
) {
pagesSubscription?.unsubscribe();
eventsSubscription?.unsubscribe();

// There seems to be no way to call opt_out_capturing without posthog
// writing to localstorage. Clearing it, instead, seems to work fine.
posthogClient?.clear_opt_in_out_capturing({
enable_persistence: false,
});
}
}
}
});
);
},
};

Expand Down
6 changes: 6 additions & 0 deletions front/app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1785,6 +1785,12 @@
"app.modules.project_folder.citizen.components.ProjectFolderSharingModal.emailSharingSubject": "{projectFolderName} | from the participation platform of {orgName}",
"app.modules.project_folder.citizen.components.ProjectFolderSharingModal.twitterMessage": "{projectFolderName}",
"app.modules.project_folder.citizen.components.ProjectFolderSharingModal.whatsAppMessage": "{projectFolderName} | from the participation platform of {orgName}",
"app.sessionRecording.accept": "Yes, I accept",
"app.sessionRecording.modalDescription1": "In order to better understand our users, we randomly ask a small percentage of visitors to track their browsing session in detail.",
"app.sessionRecording.modalDescription2": "The sole purpose of the recorded data is to improve the website. None of your data will be shared with a 3rd party. Any sensitive information you enter will be filtered.",
"app.sessionRecording.modalDescription3": "Do you accept?",
"app.sessionRecording.modalTitle": "Help us improve this website",
"app.sessionRecording.reject": "No, I reject",
"app.utils.AdminPage.ProjectEdit.conductParticipatoryBudgetingText": "Conduct a budget allocation exercise",
"app.utils.AdminPage.ProjectEdit.createDocumentAnnotation": "Collect feedback on a document",
"app.utils.AdminPage.ProjectEdit.createNativeSurvey": "Create an in-platform survey",
Expand Down
Loading

0 comments on commit e518739

Please sign in to comment.