Repository: PostHog/posthog
Pull request #29738: feat: add masking to onboarding for session replay
Author: login: joshsny
Created: 2025-03-10T17:43:49Z | Merged: 2025-03-14T16:06:39Z

## Problem

Users want to decide their privacy options for session replay when setting up the product

## Changes

Add session replay masking to onboarding

## Does this work well for both Cloud and self-hosted?

Yes

## How did you test this code?

Tested different options locally in onboarding, and also in settings.



Diff:
diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--dark.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--dark.png
index 6ab391e8db1a..2c44d9da74f2 100644
Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--dark.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--dark.png differ
diff --git a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--light.png b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--light.png
index 5c10a29101f2..51d6b26dc3b3 100644
Binary files a/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--light.png and b/frontend/__snapshots__/scenes-other-onboarding--onboarding-product-configuration--light.png differ
diff --git a/frontend/src/scenes/onboarding/Onboarding.tsx b/frontend/src/scenes/onboarding/Onboarding.tsx
index fd613467c7c0..d22e9ab8cf06 100644
--- a/frontend/src/scenes/onboarding/Onboarding.tsx
+++ b/frontend/src/scenes/onboarding/Onboarding.tsx
@@ -9,10 +9,11 @@ import { newDashboardLogic } from 'scenes/dashboard/newDashboardLogic'
 import { WebAnalyticsSDKInstructions } from 'scenes/onboarding/sdks/web-analytics/WebAnalyticsSDKInstructions'
 import { productsLogic } from 'scenes/products/productsLogic'
 import { SceneExport } from 'scenes/sceneTypes'
+import { getMaskingConfigFromLevel, getMaskingLevelFromConfig } from 'scenes/session-recordings/utils'
 import { teamLogic } from 'scenes/teamLogic'
 import { userLogic } from 'scenes/userLogic'
 
-import { AvailableFeature, ProductKey } from '~/types'
+import { AvailableFeature, ProductKey, type SessionRecordingMaskingLevel } from '~/types'
 
 import { OnboardingUpgradeStep } from './billing/OnboardingUpgradeStep'
 import { OnboardingDataWarehouseSourcesStep } from './data-warehouse/OnboardingDataWarehouseSourcesStep'
@@ -316,6 +317,24 @@ const SessionReplayOnboarding = (): JSX.Element => {
             value: true,
             visible: false,
         },
+        {
+            type: 'select',
+            title: 'Masking',
+            description: 'Choose the level of masking for your recordings.',
+            value: getMaskingLevelFromConfig(currentTeam?.session_recording_masking_config ?? {}),
+            teamProperty: 'session_recording_masking_config',
+            onChange: (value: string | number | null) => {
+                return {
+                    session_recording_masking_config: getMaskingConfigFromLevel(value as SessionRecordingMaskingLevel),
+                }
+            },
+            selectOptions: [
+                { value: 'total-privacy', label: 'Total privacy (mask all text/images)' },
+                { value: 'normal', label: 'Normal (mask inputs but not text/images)' },
+                { value: 'free-love', label: 'Free love (mask only passwords)' },
+            ],
+            visible: true,
+        },
     ]
 
     if (hasAvailableFeature(AvailableFeature.REPLAY_RECORDING_DURATION_MINIMUM)) {
@@ -411,7 +430,7 @@ export function Onboarding(): JSX.Element | null {
     if (!product || !productKey) {
         return <></>
     }
-    const OnboardingView = onboardingViews[productKey]
+    const OnboardingView = onboardingViews[productKey as keyof typeof onboardingViews]
 
     return <OnboardingView />
 }
diff --git a/frontend/src/scenes/onboarding/OnboardingProductConfiguration.tsx b/frontend/src/scenes/onboarding/OnboardingProductConfiguration.tsx
index 7ef0423bd78b..2b369f176851 100644
--- a/frontend/src/scenes/onboarding/OnboardingProductConfiguration.tsx
+++ b/frontend/src/scenes/onboarding/OnboardingProductConfiguration.tsx
@@ -65,9 +65,14 @@ export const OnboardingProductConfiguration = ({
                 onChange: (newValue: boolean | string | number) => {
                     // Use the current value from the ref to ensure that onChange always accesses
                     // the latest state of configOptions, preventing the closure from using stale data.
-                    const updatedConfigOptions = configOptionsRef.current.map((o) =>
-                        o.teamProperty === option.teamProperty ? { ...o, value: newValue } : o
-                    )
+                    const updatedConfigOptions = configOptionsRef.current.map((o) => {
+                        if (o.teamProperty === option.teamProperty) {
+                            return { ...o, value: newValue }
+                        }
+
+                        return o
+                    })
+
                     setConfigOptions(updatedConfigOptions)
                 },
             })),
@@ -119,7 +124,7 @@ export const OnboardingProductConfiguration = ({
                                         checked={item.value || false}
                                     />
                                 ) : (
-                                    <div className="flex justify-between items-center mb-1 gap-x-4">
+                                    <div className="flex justify-end items-center mb-1 gap-x-4">
                                         <LemonSelect
                                             dropdownMatchSelectWidth={false}
                                             onChange={item.onChange}
diff --git a/frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts b/frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts
index 92d9cabc67de..db1a1b5511b1 100644
--- a/frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts
+++ b/frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts
@@ -26,6 +26,7 @@ export interface ProductConfigurationSelect extends ProductConfigOptionBase {
     /** Sets the initial value. Use a team setting to reflect current state, or a static value to set a default. */
     value: string | number | null
     selectOptions: LemonSelectOptions<any>
+    onChange?: (value: string | number | null) => Partial<TeamType>
 }
 
 export type ProductConfigOption = ProductConfigurationToggle | ProductConfigurationSelect
@@ -49,13 +50,20 @@ export const onboardingProductConfigurationLogic = kea<onboardingProductConfigur
     })),
     listeners(({ values, actions }) => ({
         saveConfiguration: async () => {
-            const updateConfig = {}
+            const updateConfig: Record<string, any> = {}
+
             values.configOptions.forEach((configOption) => {
-                updateConfig[configOption.teamProperty] = configOption.inverseToggle
-                    ? !configOption.value
-                    : configOption.value
+                if (configOption.onChange) {
+                    const changes = configOption.onChange(configOption.value)
+                    Object.assign(updateConfig, changes)
+                } else {
+                    const value = configOption.inverseToggle ? !configOption.value : configOption.value
+
+                    updateConfig[configOption.teamProperty] = value
+                }
             })
-            actions.updateCurrentTeam(updateConfig)
+
+            actions.updateCurrentTeam(updateConfig as Partial<TeamType>)
         },
     })),
 ])
diff --git a/frontend/src/scenes/session-recordings/utils.ts b/frontend/src/scenes/session-recordings/utils.ts
index b18fed7316a8..f6dcc1ae71b5 100644
--- a/frontend/src/scenes/session-recordings/utils.ts
+++ b/frontend/src/scenes/session-recordings/utils.ts
@@ -1,4 +1,11 @@
-import { LegacyRecordingFilters, RecordingUniversalFilters, UniversalFiltersGroup, UniversalFilterValue } from '~/types'
+import {
+    LegacyRecordingFilters,
+    RecordingUniversalFilters,
+    type SessionRecordingMaskingConfig,
+    type SessionRecordingMaskingLevel,
+    UniversalFiltersGroup,
+    UniversalFilterValue,
+} from '~/types'
 
 export const TimestampFormatToLabel = {
     relative: 'Relative',
@@ -17,3 +24,27 @@ export const filtersFromUniversalFilterGroups = (filters: RecordingUniversalFilt
     const group = filters.filter_group.values[0] as UniversalFiltersGroup
     return group.values as UniversalFilterValue[]
 }
+
+export const getMaskingLevelFromConfig = (config: SessionRecordingMaskingConfig): SessionRecordingMaskingLevel => {
+    if (config.maskTextSelector === '*' && config.maskAllInputs && config.blockSelector === 'img') {
+        return 'total-privacy'
+    }
+
+    if (config.maskTextSelector === undefined && !config.maskAllInputs) {
+        return 'free-love'
+    }
+
+    return 'normal'
+}
+
+export const getMaskingConfigFromLevel = (level: SessionRecordingMaskingLevel): SessionRecordingMaskingConfig => {
+    if (level === 'total-privacy') {
+        return { maskTextSelector: '*', maskAllInputs: true, blockSelector: 'img' }
+    }
+
+    if (level === 'free-love') {
+        return { maskTextSelector: undefined, maskAllInputs: false, blockSelector: undefined }
+    }
+
+    return { maskTextSelector: undefined, maskAllInputs: true, blockSelector: undefined }
+}
diff --git a/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx b/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
index e4f81309d8b8..269131a36245 100644
--- a/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
+++ b/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
@@ -23,6 +23,7 @@ import { IconSelectEvents } from 'lib/lemon-ui/icons'
 import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel'
 import { isObject, objectsEqual } from 'lib/utils'
 import { ReactNode, useState } from 'react'
+import { getMaskingConfigFromLevel, getMaskingLevelFromConfig } from 'scenes/session-recordings/utils'
 import { teamLogic } from 'scenes/teamLogic'
 
 import { SessionRecordingAIConfig, type SessionRecordingMaskingLevel } from '~/types'
@@ -555,12 +556,7 @@ export function ReplayMaskingSettings(): JSX.Element {
 
     const handleMaskingChange = (level: SessionRecordingMaskingLevel): void => {
         updateCurrentTeam({
-            session_recording_masking_config: {
-                ...currentTeam?.session_recording_masking_config,
-                maskAllInputs: level !== 'free-love',
-                maskTextSelector: level === 'total-privacy' ? '*' : undefined,
-                blockSelector: level === 'total-privacy' ? 'img' : undefined,
-            },
+            session_recording_masking_config: getMaskingConfigFromLevel(level),
         })
     }
 
@@ -570,12 +566,7 @@ export function ReplayMaskingSettings(): JSX.Element {
         blockSelector: currentTeam?.session_recording_masking_config?.blockSelector,
     }
 
-    const maskingLevel =
-        maskingConfig.maskTextSelector === '*' && maskingConfig.maskAllInputs
-            ? 'total-privacy'
-            : maskingConfig.maskTextSelector === undefined && !maskingConfig.maskAllInputs
-            ? 'free-love'
-            : 'normal'
+    const maskingLevel = getMaskingLevelFromConfig(maskingConfig)
 
     return (
         <div>


Reviews:
user: login: joshsny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T17:44:57Z
user: login: greptile-apps[bot]
body: 
## PR Summary

Added session recording masking configuration to the onboarding flow, allowing users to set privacy preferences during initial setup.

- Added masking level selection in `SessionReplayOnboarding` with three privacy options: total-privacy, normal, and free-love
- Introduced utility functions `getMaskingLevelFromConfig` and `getMaskingConfigFromLevel` in `frontend/src/scenes/session-recordings/utils.ts` for consistent masking handling
- Modified `onboardingProductConfigurationLogic.ts` to support optional `teamProperty` and `onChange` callbacks for dynamic config updates
- Refactored `ReplayMaskingSettings` component to use shared masking utility functions for consistency between onboarding and settings
- Added ref-based state management in `OnboardingProductConfiguration.tsx` to prevent stale closure issues with config options

<sub>5 file(s) reviewed, 3 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_29738)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T17:45:00Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T18:45:37Z
user: login: pauldambra
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T18:46:55Z
user: login: pauldambra
body: didn't run it 🙈 but LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T18:47:06Z
user: login: zlwaterfield
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T09:43:58Z

Review comments:
diff_hunk: @@ -26,6 +26,7 @@ export interface ProductConfigurationSelect extends ProductConfigOptionBase {
     /** Sets the initial value. Use a team setting to reflect current state, or a static value to set a default. */
     value: string | number | null
     selectOptions: LemonSelectOptions<any>
+    onChange?: (value: string | number | null) => Partial<TeamType>
path: frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts
user: login: greptile-apps[bot]
body: style: LemonSelectOptions&lt;any&gt; could be made more type-safe by specifying the generic type parameter
created_at: 2025-03-10T17:44:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,9 +65,18 @@ export const OnboardingProductConfiguration = ({
                 onChange: (newValue: boolean | string | number) => {
                     // Use the current value from the ref to ensure that onChange always accesses
                     // the latest state of configOptions, preventing the closure from using stale data.
-                    const updatedConfigOptions = configOptionsRef.current.map((o) =>
-                        o.teamProperty === option.teamProperty ? { ...o, value: newValue } : o
-                    )
+                    const updatedConfigOptions = configOptionsRef.current.map((o) => {
+                        if (o.teamProperty === option.teamProperty) {
+                            return { ...o, value: newValue }
+                        }
+
+                        if (o.onChange) {
+                            return { ...o, ...o.onChange(newValue) }
+                        }
path: frontend/src/scenes/onboarding/OnboardingProductConfiguration.tsx
user: login: greptile-apps[bot]
body: logic: Spreading both o and o.onChange(newValue) could cause unintended overwrites if onChange returns properties that exist in o
created_at: 2025-03-10T17:44:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,11 +50,19 @@ export const onboardingProductConfigurationLogic = kea<onboardingProductConfigur
     })),
     listeners(({ values, actions }) => ({
         saveConfiguration: async () => {
-            const updateConfig = {}
+            let updateConfig: Partial<TeamType> = {}
             values.configOptions.forEach((configOption) => {
-                updateConfig[configOption.teamProperty] = configOption.inverseToggle
-                    ? !configOption.value
-                    : configOption.value
+                if (configOption.teamProperty) {
+                    updateConfig[configOption.teamProperty as keyof TeamType] = configOption.inverseToggle
+                        ? !configOption.value
+                        : configOption.value
+                }
+                if (configOption.onChange) {
+                    updateConfig = {
+                        ...updateConfig,
+                        ...configOption.onChange(configOption.value),
+                    }
+                }
path: frontend/src/scenes/onboarding/onboardingProductConfigurationLogic.ts
user: login: greptile-apps[bot]
body: logic: Order of operations means later onChange handlers could overwrite earlier teamProperty settings
created_at: 2025-03-10T17:44:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,3 +24,27 @@ export const filtersFromUniversalFilterGroups = (filters: RecordingUniversalFilt
     const group = filters.filter_group.values[0] as UniversalFiltersGroup
     return group.values as UniversalFilterValue[]
 }
+
+export const getMaskingLevelFromConfig = (config: SessionRecordingMaskingConfig): SessionRecordingMaskingLevel => {
+    if (config.maskTextSelector === '*' && config.maskAllInputs) {
+        return 'total-privacy'
+    }
+
+    if (config.maskTextSelector === undefined && !config.maskAllInputs) {
+        return 'free-love'
+    }
+
+    return 'normal'
path: frontend/src/scenes/session-recordings/utils.ts
user: login: joshsny
body: Defaulting to normal here is a bit off, as it could technically not be "normal", thoughts @pauldambra?
created_at: 2025-03-10T17:44:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,3 +24,27 @@ export const filtersFromUniversalFilterGroups = (filters: RecordingUniversalFilt
     const group = filters.filter_group.values[0] as UniversalFiltersGroup
     return group.values as UniversalFilterValue[]
 }
+
+export const getMaskingLevelFromConfig = (config: SessionRecordingMaskingConfig): SessionRecordingMaskingLevel => {
+    if (config.maskTextSelector === '*' && config.maskAllInputs) {
+        return 'total-privacy'
+    }
+
+    if (config.maskTextSelector === undefined && !config.maskAllInputs) {
+        return 'free-love'
+    }
+
+    return 'normal'
path: frontend/src/scenes/session-recordings/utils.ts
user: login: pauldambra
body: well, that's the current default so i think it's safe 👍 
created_at: 2025-03-11T18:45:37Z
author_association: MEMBER
diff_hunk: @@ -316,6 +317,23 @@ const SessionReplayOnboarding = (): JSX.Element => {
             value: true,
             visible: false,
         },
+        {
+            type: 'select',
path: frontend/src/scenes/onboarding/Onboarding.tsx
user: login: pauldambra
body: does this add a data-attr or something unique that we can use to track people's choices here?
created_at: 2025-03-11T18:46:55Z
author_association: MEMBER

PR comments:
user: login: posthog-bot
created_at: 2025-03-10T18:03:39Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/29738/commits/f08e1ecf55c9f0ffa6f6f4ccb95ed7db09c112f9))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29738/commits/5bf6a5dad739dc815194e1496a2cb8cfb020ff91).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29738/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-11T10:24:43Z
body: **Size Change:** -10 B (0%) 

**Total Size:** 9.74 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 9.74 MB | -10 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR