Skip to content

Commit

Permalink
feat(replay): allow triggering session recording based on urls (#25451)
Browse files Browse the repository at this point in the history
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Matloka <[email protected]>
  • Loading branch information
3 people authored and timgl committed Oct 17, 2024
1 parent 28f6ec8 commit f5e0af4
Show file tree
Hide file tree
Showing 30 changed files with 971 additions and 284 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const FEATURE_FLAGS = {
ENVIRONMENTS: 'environments', // owner: @Twixes #team-product-analytics
BILLING_PAYMENT_ENTRY_IN_APP: 'billing-payment-entry-in-app', // owner: @zach
LEGACY_ACTION_WEBHOOKS: 'legacy-action-webhooks', // owner: @mariusandra #team-cdp
SESSION_REPLAY_URL_TRIGGER: 'session-replay-url-trigger', // owner: @richard-better #team-replay
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]

Expand Down
143 changes: 136 additions & 7 deletions frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { IconPlus } from '@posthog/icons'
import { IconPencil, IconPlus, IconTrash } from '@posthog/icons'
import {
LemonBanner,
LemonButton,
LemonDialog,
LemonInput,
LemonSegmentedButton,
LemonSegmentedButtonOption,
LemonSelect,
Expand All @@ -11,23 +12,32 @@ import {
Link,
Spinner,
} from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUrlList'
import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { EventSelect } from 'lib/components/EventSelect/EventSelect'
import { FlaggedFeature } from 'lib/components/FlaggedFeature'
import { FlagSelector } from 'lib/components/FlagSelector'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { PropertySelect } from 'lib/components/PropertySelect/PropertySelect'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants'
import { FEATURE_FLAGS, SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants'
import { IconCancel, IconSelectEvents } from 'lib/lemon-ui/icons'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel'
import { objectsEqual } from 'lib/utils'
import { sessionReplayLinkedFlagLogic } from 'scenes/settings/environment/sessionReplayLinkedFlagLogic'
import { sessionReplayIngestionControlLogic } from 'scenes/settings/environment/sessionReplayIngestionControlLogic'
import { teamLogic } from 'scenes/teamLogic'
import { userLogic } from 'scenes/userLogic'

import { AvailableFeature, MultivariateFlagOptions, SessionRecordingAIConfig } from '~/types'
import {
AvailableFeature,
MultivariateFlagOptions,
SessionRecordingAIConfig,
SessionReplayUrlTriggerConfig,
} from '~/types'

function LogCaptureSettings(): JSX.Element {
const { updateCurrentTeam } = useActions(teamLogic)
Expand Down Expand Up @@ -259,9 +269,8 @@ function LinkedFlagSelector(): JSX.Element | null {

const featureFlagRecordingFeatureEnabled = hasAvailableFeature(AvailableFeature.REPLAY_FEATURE_FLAG_BASED_RECORDING)

const logic = sessionReplayLinkedFlagLogic({ id: currentTeam?.session_recording_linked_flag?.id || null })
const { linkedFlag, featureFlagLoading, flagHasVariants } = useValues(logic)
const { selectFeatureFlag } = useActions(logic)
const { linkedFlag, featureFlagLoading, flagHasVariants } = useValues(sessionReplayIngestionControlLogic)
const { selectFeatureFlag } = useActions(sessionReplayIngestionControlLogic)

if (!featureFlagRecordingFeatureEnabled) {
return null
Expand Down Expand Up @@ -330,6 +339,123 @@ function LinkedFlagSelector(): JSX.Element | null {
)
}

function UrlTriggerForm(): JSX.Element {
const { cancelProposingUrlTrigger } = useActions(sessionReplayIngestionControlLogic)
const { isProposedUrlTriggerSubmitting } = useValues(sessionReplayIngestionControlLogic)

return (
<Form
logic={sessionReplayIngestionControlLogic}
formKey="proposedUrlTrigger"
enableFormOnSubmit
className="w-full flex flex-col border rounded items-center p-2 pl-4 bg-bg-light gap-2"
>
<div className="flex flex-row gap-2 w-full">
<LemonField name="matching">
<LemonSelect options={[{ label: 'Regex', value: 'regex' }]} />
</LemonField>
<LemonField name="url" className="flex-1">
<LemonInput autoFocus placeholder="Enter URL" data-attr="url-input" />
</LemonField>
</div>
<div className="flex justify-end gap-2 w-full">
<LemonButton type="secondary" onClick={cancelProposingUrlTrigger}>
Cancel
</LemonButton>
<LemonButton
htmlType="submit"
type="primary"
disabledReason={isProposedUrlTriggerSubmitting ? 'Saving url trigger in progress' : undefined}
data-attr="url-save"
>
Save
</LemonButton>
</div>
</Form>
)
}

function UrlTriggerRow({ trigger, index }: { trigger: SessionReplayUrlTriggerConfig; index: number }): JSX.Element {
const { editUrlTriggerIndex } = useValues(sessionReplayIngestionControlLogic)
const { setEditUrlTriggerIndex, removeUrlTrigger } = useActions(sessionReplayIngestionControlLogic)

if (editUrlTriggerIndex === index) {
return (
<div className="border rounded p-2 bg-bg-light">
<UrlTriggerForm />
</div>
)
}

return (
<div className={clsx('border rounded flex items-center p-2 pl-4 bg-bg-light')}>
<span title={trigger.url} className="flex-1 truncate">
{trigger.matching === 'regex' ? 'Matches regex: ' : ''} {trigger.url}
</span>
<div className="Actions flex space-x-1 shrink-0">
<LemonButton
icon={<IconPencil />}
onClick={() => setEditUrlTriggerIndex(index)}
tooltip="Edit"
center
/>

<LemonButton
icon={<IconTrash />}
tooltip="Remove URL trigger"
center
onClick={() => {
LemonDialog.open({
title: <>Remove URL trigger</>,
description: `Are you sure you want to remove this URL trigger?`,
primaryButton: {
status: 'danger',
children: 'Remove',
onClick: () => removeUrlTrigger(index),
},
secondaryButton: {
children: 'Cancel',
},
})
}}
/>
</div>
</div>
)
}

function UrlTriggerOptions(): JSX.Element | null {
const { isAddUrlTriggerConfigFormVisible, urlTriggerConfig } = useValues(sessionReplayIngestionControlLogic)
const { newUrlTrigger } = useActions(sessionReplayIngestionControlLogic)

return (
<div className="flex flex-col space-y-2 mt-4">
<div className="flex items-center gap-2 justify-between">
<LemonLabel className="text-base">Enable recordings when URL matches</LemonLabel>
<LemonButton
onClick={() => {
newUrlTrigger()
}}
type="secondary"
icon={<IconPlus />}
data-attr="session-replay-add-url-trigger"
>
Add
</LemonButton>
</div>
<p>
Adding a URL trigger means recording will only be started when the user visits a page that matches the
URL.
</p>

{isAddUrlTriggerConfigFormVisible && <UrlTriggerForm />}
{urlTriggerConfig?.map((trigger, index) => (
<UrlTriggerRow key={`${trigger.url}-${trigger.matching}`} trigger={trigger} index={index} />
))}
</div>
)
}

export function ReplayCostControl(): JSX.Element | null {
const { updateCurrentTeam } = useActions(teamLogic)
const { currentTeam } = useValues(teamLogic)
Expand Down Expand Up @@ -484,6 +610,9 @@ export function ReplayCostControl(): JSX.Element | null {
</>
)}
<LinkedFlagSelector />
<FlaggedFeature flag={FEATURE_FLAGS.SESSION_REPLAY_URL_TRIGGER}>
<UrlTriggerOptions />
</FlaggedFeature>
</>
</PayGateMini>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { actions, afterMount, connect, kea, listeners, path, props, reducers, selectors, sharedListeners } from 'kea'
import { forms } from 'kea-forms'
import { loaders } from 'kea-loaders'
import { subscriptions } from 'kea-subscriptions'
import api from 'lib/api'
import { isObject } from 'lib/utils'
import { variantKeyToIndexFeatureFlagPayloads } from 'scenes/feature-flags/featureFlagLogic'
import { teamLogic } from 'scenes/teamLogic'

import { FeatureFlagBasicType, SessionReplayUrlTriggerConfig, TeamPublicType, TeamType } from '~/types'

import type { sessionReplayIngestionControlLogicType } from './sessionReplayIngestionControlLogicType'

const NEW_URL_TRIGGER = { url: '', matching: 'regex' }

export const sessionReplayIngestionControlLogic = kea<sessionReplayIngestionControlLogicType>([
path(['scenes', 'settings', 'project', 'sessionReplayIngestionControlLogic']),
actions({
selectFeatureFlag: (flag: FeatureFlagBasicType) => ({ flag }),
setUrlTriggerConfig: (urlTriggerConfig: SessionReplayUrlTriggerConfig[]) => ({ urlTriggerConfig }),

addUrlTrigger: (urlTriggerConfig: SessionReplayUrlTriggerConfig) => ({ urlTriggerConfig }),
removeUrlTrigger: (index: number) => ({ index }),
updateUrlTrigger: (index: number, urlTriggerConfig: SessionReplayUrlTriggerConfig) => ({
index,
urlTriggerConfig,
}),
setEditUrlTriggerIndex: (originalIndex: number | null) => ({ originalIndex }),
newUrlTrigger: true,
cancelProposingUrlTrigger: true,
}),
connect({ values: [teamLogic, ['currentTeam']], actions: [teamLogic, ['updateCurrentTeam']] }),
reducers({
selectedFlag: [
null as FeatureFlagBasicType | null,
{
selectFeatureFlag: (_, { flag }) => flag,
},
],
urlTriggerConfig: [
null as SessionReplayUrlTriggerConfig[] | null,
{
setUrlTriggerConfig: (_, { urlTriggerConfig }) => urlTriggerConfig,
addUrlTrigger: (state, { urlTriggerConfig }) => [...(state ?? []), urlTriggerConfig],
updateUrlTrigger: (state, { index, urlTriggerConfig: newUrlTriggerConfig }) =>
(state ?? []).map((triggerConfig, i) => (i === index ? newUrlTriggerConfig : triggerConfig)),
removeUrlTrigger: (state, { index }) => {
return (state ?? []).filter((_, i) => i !== index)
},
},
],
editUrlTriggerIndex: [
null as number | null,
{
setEditUrlTriggerIndex: (_, { originalIndex }) => originalIndex,
removeUrlTrigger: (editUrlTriggerIndex, { index }) =>
editUrlTriggerIndex && index < editUrlTriggerIndex
? editUrlTriggerIndex - 1
: index === editUrlTriggerIndex
? null
: editUrlTriggerIndex,
newUrlTrigger: () => -1,
updateUrlTrigger: () => null,
addUrlTrigger: () => null,
cancelProposingUrlTrigger: () => null,
},
],
}),
props({}),
loaders(({ values }) => ({
featureFlag: {
loadFeatureFlag: async () => {
if (values.linkedFeatureFlagId) {
const retrievedFlag = await api.featureFlags.get(values.linkedFeatureFlagId)
return variantKeyToIndexFeatureFlagPayloads(retrievedFlag)
}
return null
},
},
})),
selectors({
linkedFeatureFlagId: [
(s) => [s.currentTeam],
(currentTeam) => currentTeam?.session_recording_linked_flag?.id || null,
],
linkedFlag: [
(s) => [s.featureFlag, s.selectedFlag, s.currentTeam],
// an existing linked flag is loaded from the API,
// a newly chosen flag is selected can be passed in
// the current team is used to ensure that we don't show stale values
// as people change the selection
(featureFlag, selectedFlag, currentTeam) =>
currentTeam?.session_recording_linked_flag?.id ? selectedFlag || featureFlag : null,
],
flagHasVariants: [(s) => [s.linkedFlag], (linkedFlag) => isObject(linkedFlag?.filters.multivariate)],
remoteUrlTriggerConfig: [
(s) => [s.currentTeam],
(currentTeam) => currentTeam?.session_recording_url_trigger_config,
],
isAddUrlTriggerConfigFormVisible: [
(s) => [s.editUrlTriggerIndex],
(editUrlTriggerIndex) => editUrlTriggerIndex === -1,
],
urlTriggerToEdit: [
(s) => [s.urlTriggerConfig, s.editUrlTriggerIndex],
(urlTriggerConfig, editUrlTriggerIndex) => {
if (
editUrlTriggerIndex === null ||
editUrlTriggerIndex === -1 ||
!urlTriggerConfig?.[editUrlTriggerIndex]
) {
return NEW_URL_TRIGGER
}
return urlTriggerConfig[editUrlTriggerIndex]
},
],
}),
afterMount(({ actions }) => {
actions.loadFeatureFlag()
}),
subscriptions(({ actions }) => ({
currentTeam: (currentTeam: TeamPublicType | TeamType | null) => {
actions.setUrlTriggerConfig(currentTeam?.session_recording_url_trigger_config ?? [])
},
})),
forms(({ values, actions }) => ({
proposedUrlTrigger: {
defaults: { url: '', matching: 'regex' } as SessionReplayUrlTriggerConfig,
submit: async ({ url, matching }) => {
if (values.editUrlTriggerIndex !== null && values.editUrlTriggerIndex >= 0) {
actions.updateUrlTrigger(values.editUrlTriggerIndex, { url, matching })
} else {
actions.addUrlTrigger({ url, matching })
}
},
},
})),
sharedListeners(({ values }) => ({
saveUrlTriggers: async () => {
await teamLogic.asyncActions.updateCurrentTeam({
session_recording_url_trigger_config: values.urlTriggerConfig ?? [],
})
},
})),
listeners(({ sharedListeners, actions, values }) => ({
setEditUrlTriggerIndex: () => {
actions.setProposedUrlTriggerValue('url', values.urlTriggerToEdit.url)
actions.setProposedUrlTriggerValue('matching', values.urlTriggerToEdit.matching)
},
addUrlTrigger: sharedListeners.saveUrlTriggers,
removeUrlTrigger: sharedListeners.saveUrlTriggers,
updateUrlTrigger: sharedListeners.saveUrlTriggers,
submitProposedUrlTriggerSuccess: () => {
actions.setEditUrlTriggerIndex(null)
actions.resetProposedUrlTrigger()
},
})),
])
Loading

0 comments on commit f5e0af4

Please sign in to comment.