From b8b90c184b760f79db27acc795af5e5583bb6b9a Mon Sep 17 00:00:00 2001 From: Bianca Yang Date: Tue, 2 Jan 2024 11:37:49 -0800 Subject: [PATCH] feat: Feature gate session replay controls using available_product_features (#19401) * feature gate session replay control using available_product_features * separate out feature checks * refine the separate feature checks * Update query snapshots * Update UI snapshots for `chromium` (2) * Update query snapshots * Update UI snapshots for `chromium` (2) * Update UI snapshots for `chromium` (2) * Update UI snapshots for `chromium` (2) * Update UI snapshots for `chromium` (2) * Update UI snapshots for `chromium` (2) * Update UI snapshots for `chromium` (2) --------- Co-authored-by: Bianca Yang Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- frontend/src/scenes/onboarding/Onboarding.tsx | 7 +- frontend/src/scenes/settings/SettingsMap.tsx | 7 + .../project/SessionRecordingSettings.tsx | 343 ++++++++++-------- frontend/src/scenes/settings/settingsLogic.ts | 21 +- frontend/src/scenes/settings/types.ts | 3 + frontend/src/types.ts | 5 +- 6 files changed, 220 insertions(+), 166 deletions(-) diff --git a/frontend/src/scenes/onboarding/Onboarding.tsx b/frontend/src/scenes/onboarding/Onboarding.tsx index 85748159d16da..d0a31a536f730 100644 --- a/frontend/src/scenes/onboarding/Onboarding.tsx +++ b/frontend/src/scenes/onboarding/Onboarding.tsx @@ -4,8 +4,9 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { useEffect, useState } from 'react' import { SceneExport } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' +import { userLogic } from 'scenes/userLogic' -import { ProductKey } from '~/types' +import { AvailableFeature, ProductKey } from '~/types' import { OnboardingBillingStep } from './OnboardingBillingStep' import { OnboardingInviteTeammates } from './OnboardingInviteTeammates' @@ -109,7 +110,7 @@ const ProductAnalyticsOnboarding = (): JSX.Element => { ) } const SessionReplayOnboarding = (): JSX.Element => { - const { featureFlags } = useValues(featureFlagLogic) + const { hasAvailableFeature } = useValues(userLogic) const configOptions: ProductConfigOption[] = [ { type: 'toggle', @@ -129,7 +130,7 @@ const SessionReplayOnboarding = (): JSX.Element => { }, ] - if (featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] === true) { + if (hasAvailableFeature(AvailableFeature.RECORDING_DURATION_MINIMUM)) { configOptions.push({ type: 'select', title: 'Minimum session duration (seconds)', diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx index 7056d62ee4996..8ed352a5bf88d 100644 --- a/frontend/src/scenes/settings/SettingsMap.tsx +++ b/frontend/src/scenes/settings/SettingsMap.tsx @@ -1,3 +1,5 @@ +import { AvailableFeature } from '~/types' + import { Invites } from './organization/Invites' import { Members } from './organization/Members' import { OrganizationDangerZone } from './organization/OrganizationDangerZone' @@ -158,6 +160,11 @@ export const SettingsMap: SettingSection[] = [ title: 'Ingestion controls', component: , flag: 'SESSION_RECORDING_SAMPLING', + features: [ + AvailableFeature.SESSION_REPLAY_SAMPLING, + AvailableFeature.RECORDING_DURATION_MINIMUM, + AvailableFeature.FEATURE_FLAG_BASED_RECORDING, + ], }, ], }, diff --git a/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx b/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx index 401342663d11a..703b129a51cc8 100644 --- a/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx +++ b/frontend/src/scenes/settings/project/SessionRecordingSettings.tsx @@ -7,8 +7,12 @@ import { FlagSelector } from 'lib/components/FlagSelector' import { FEATURE_FLAGS, SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants' import { IconCancel } from 'lib/lemon-ui/icons' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' +import { userLogic } from 'scenes/userLogic' + +import { AvailableFeature } from '~/types' export function ReplayGeneral(): JSX.Element { const { updateCurrentTeam } = useActions(teamLogic) @@ -176,168 +180,191 @@ export function ReplayAuthorizedDomains(): JSX.Element { export function ReplayCostControl(): JSX.Element { const { updateCurrentTeam } = useActions(teamLogic) const { currentTeam } = useValues(teamLogic) + const { hasAvailableFeature } = useValues(userLogic) + const { featureFlags } = useValues(featureFlagLogic) + const samplingControlFeatureEnabled = hasAvailableFeature(AvailableFeature.SESSION_REPLAY_SAMPLING) + const recordingDurationMinimumFeatureEnabled = hasAvailableFeature(AvailableFeature.RECORDING_DURATION_MINIMUM) + const featureFlagRecordingFeatureEnabled = hasAvailableFeature(AvailableFeature.FEATURE_FLAG_BASED_RECORDING) + const costControlFeaturesEnabled = + featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || + samplingControlFeatureEnabled || + recordingDurationMinimumFeatureEnabled || + featureFlagRecordingFeatureEnabled - return ( - - <> -

- PostHog offers several tools to let you control the number of recordings you collect and which users - you collect recordings for.{' '} - - Learn more in our docs - -

- - Requires posthog-js version 1.88.2 or greater - -
- Sampling - { - updateCurrentTeam({ session_recording_sample_rate: v }) - }} - dropdownMatchSelectWidth={false} - options={[ - { - label: '100% (no sampling)', - value: '1.00', - }, - { - label: '95%', - value: '0.95', - }, - { - label: '90%', - value: '0.90', - }, - { - label: '85%', - value: '0.85', - }, - { - label: '80%', - value: '0.80', - }, - { - label: '75%', - value: '0.75', - }, - { - label: '70%', - value: '0.70', - }, - { - label: '65%', - value: '0.65', - }, - { - label: '60%', - value: '0.60', - }, - { - label: '55%', - value: '0.55', - }, - { - label: '50%', - value: '0.50', - }, - { - label: '45%', - value: '0.45', - }, - { - label: '40%', - value: '0.40', - }, - { - label: '35%', - value: '0.35', - }, - { - label: '30%', - value: '0.30', - }, - { - label: '25%', - value: '0.25', - }, - { - label: '20%', - value: '0.20', - }, - { - label: '15%', - value: '0.15', - }, - { - label: '10%', - value: '0.10', - }, - { - label: '5%', - value: '0.05', - }, - { - label: '0% (replay disabled)', - value: '0.00', - }, - ]} - value={ - typeof currentTeam?.session_recording_sample_rate === 'string' - ? currentTeam?.session_recording_sample_rate - : '1.00' - } - /> -
-

- Use this setting to restrict the percentage of sessions that will be recorded. This is useful if you - want to reduce the amount of data you collect. 100% means all sessions will be collected. 50% means - roughly half of sessions will be collected. -

-
- Minimum session duration (seconds) - { - updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v }) - }} - options={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS} - value={currentTeam?.session_recording_minimum_duration_milliseconds} - /> -
-

- Setting a minimum session duration will ensure that only sessions that last longer than that value - are collected. This helps you avoid collecting sessions that are too short to be useful. -

-
- Enable recordings using feature flag -
- { - updateCurrentTeam({ session_recording_linked_flag: { id, key } }) + return costControlFeaturesEnabled ? ( + <> +

+ PostHog offers several tools to let you control the number of recordings you collect and which users you + collect recordings for.{' '} + + Learn more in our docs + +

+ + Requires posthog-js version 1.88.2 or greater + + {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || samplingControlFeatureEnabled) && ( + <> +
+ Sampling + { + updateCurrentTeam({ session_recording_sample_rate: v }) + }} + dropdownMatchSelectWidth={false} + options={[ + { + label: '100% (no sampling)', + value: '1.00', + }, + { + label: '95%', + value: '0.95', + }, + { + label: '90%', + value: '0.90', + }, + { + label: '85%', + value: '0.85', + }, + { + label: '80%', + value: '0.80', + }, + { + label: '75%', + value: '0.75', + }, + { + label: '70%', + value: '0.70', + }, + { + label: '65%', + value: '0.65', + }, + { + label: '60%', + value: '0.60', + }, + { + label: '55%', + value: '0.55', + }, + { + label: '50%', + value: '0.50', + }, + { + label: '45%', + value: '0.45', + }, + { + label: '40%', + value: '0.40', + }, + { + label: '35%', + value: '0.35', + }, + { + label: '30%', + value: '0.30', + }, + { + label: '25%', + value: '0.25', + }, + { + label: '20%', + value: '0.20', + }, + { + label: '15%', + value: '0.15', + }, + { + label: '10%', + value: '0.10', + }, + { + label: '5%', + value: '0.05', + }, + { + label: '0% (replay disabled)', + value: '0.00', + }, + ]} + value={ + typeof currentTeam?.session_recording_sample_rate === 'string' + ? currentTeam?.session_recording_sample_rate + : '1.00' + } + /> +
+

+ Use this setting to restrict the percentage of sessions that will be recorded. This is useful if + you want to reduce the amount of data you collect. 100% means all sessions will be collected. + 50% means roughly half of sessions will be collected. +

+ + )} + {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || recordingDurationMinimumFeatureEnabled) && ( + <> +
+ Minimum session duration (seconds) + { + updateCurrentTeam({ session_recording_minimum_duration_milliseconds: v }) }} + options={SESSION_REPLAY_MINIMUM_DURATION_OPTIONS} + value={currentTeam?.session_recording_minimum_duration_milliseconds} /> - {currentTeam?.session_recording_linked_flag && ( - } - size="small" - onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })} - title="Clear selected flag" +
+

+ Setting a minimum session duration will ensure that only sessions that last longer than that + value are collected. This helps you avoid collecting sessions that are too short to be useful. +

+ + )} + {(featureFlags[FEATURE_FLAGS.SESSION_RECORDING_SAMPLING] || featureFlagRecordingFeatureEnabled) && ( + <> +
+ Enable recordings using feature flag +
+ { + updateCurrentTeam({ session_recording_linked_flag: { id, key } }) + }} /> - )} + {currentTeam?.session_recording_linked_flag && ( + } + size="small" + type="secondary" + onClick={() => updateCurrentTeam({ session_recording_linked_flag: null })} + title="Clear selected flag" + /> + )} +
-
-

- Linking a flag means that recordings will only be collected for users who have the flag enabled. - Only supports release toggles (boolean flags). -

- - +

+ Linking a flag means that recordings will only be collected for users who have the flag enabled. + Only supports release toggles (boolean flags). +

+ + )} + + ) : ( + <> ) } diff --git a/frontend/src/scenes/settings/settingsLogic.ts b/frontend/src/scenes/settings/settingsLogic.ts index 8af9bb55f8475..f727e2a2dfe50 100644 --- a/frontend/src/scenes/settings/settingsLogic.ts +++ b/frontend/src/scenes/settings/settingsLogic.ts @@ -3,6 +3,7 @@ import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { copyToClipboard } from 'lib/utils/copyToClipboard' import { urls } from 'scenes/urls' +import { userLogic } from 'scenes/userLogic' import type { settingsLogicType } from './settingsLogicType' import { SettingsMap } from './SettingsMap' @@ -13,7 +14,7 @@ export const settingsLogic = kea([ key((props) => props.logicKey ?? 'global'), path((key) => ['scenes', 'settings', 'settingsLogic', key]), connect({ - values: [featureFlagLogic, ['featureFlags']], + values: [featureFlagLogic, ['featureFlags'], userLogic, ['hasAvailableFeature']], }), actions({ @@ -65,8 +66,8 @@ export const settingsLogic = kea([ }, ], settings: [ - (s) => [s.selectedLevel, s.selectedSectionId, s.sections, s.featureFlags], - (selectedLevel, selectedSectionId, sections, featureFlags): Setting[] => { + (s) => [s.selectedLevel, s.selectedSectionId, s.sections, s.featureFlags, s.hasAvailableFeature], + (selectedLevel, selectedSectionId, sections, featureFlags, hasAvailableFeature): Setting[] => { let settings: Setting[] = [] if (!selectedSectionId) { @@ -77,7 +78,19 @@ export const settingsLogic = kea([ settings = sections.find((x) => x.id === selectedSectionId)?.settings || [] } - return settings.filter((x) => (x.flag ? featureFlags[FEATURE_FLAGS[x.flag]] : true)) + return settings.filter((x) => { + if (x.flag && x.features) { + return ( + x.features.some((feat) => hasAvailableFeature(feat)) || featureFlags[FEATURE_FLAGS[x.flag]] + ) + } else if (x.features) { + return x.features.some((feat) => hasAvailableFeature(feat)) + } else if (x.flag) { + return featureFlags[FEATURE_FLAGS[x.flag]] + } + + return true + }) }, ], }), diff --git a/frontend/src/scenes/settings/types.ts b/frontend/src/scenes/settings/types.ts index d57e2273fc765..6dad2d9f75193 100644 --- a/frontend/src/scenes/settings/types.ts +++ b/frontend/src/scenes/settings/types.ts @@ -1,5 +1,7 @@ import { EitherMembershipLevel, FEATURE_FLAGS } from 'lib/constants' +import { AvailableFeature } from '~/types' + export type SettingsLogicProps = { logicKey?: string // Optional - if given, renders only the given level @@ -78,6 +80,7 @@ export type Setting = { description?: JSX.Element | string component: JSX.Element flag?: keyof typeof FEATURE_FLAGS + features?: AvailableFeature[] } export type SettingSection = { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 36483bbdd9eb5..efdb944d97756 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -41,7 +41,7 @@ import { NodeKind } from './queries/schema' export type Optional = Omit & { [K in keyof T]?: T[K] } -// Keep this in sync with backend constants (constants.py) +// Keep this in sync with backend constants/features/{product_name}.yml export enum AvailableFeature { EVENTS = 'events', TRACKED_USERS = 'tracked_users', @@ -91,6 +91,9 @@ export enum AvailableFeature { SURVEYS_STYLING = 'surveys_styling', SURVEYS_TEXT_HTML = 'surveys_text_html', SURVEYS_MULTIPLE_QUESTIONS = 'surveys_multiple_questions', + SESSION_REPLAY_SAMPLING = 'session_replay_sampling', + RECORDING_DURATION_MINIMUM = 'replay_recording_duration_minimum', + FEATURE_FLAG_BASED_RECORDING = 'replay_feature_flag_based_recording', } export enum ProductKey {