Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): allow triggering session recording based on urls #25451

Merged
merged 37 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5fe4a5f
add new property for url triggers
Oct 8, 2024
6299abd
refactor: rename logic file to be more generic
Oct 8, 2024
144ced4
refactor: remove feature flag id from props, read from team logic
Oct 8, 2024
57ee738
add ui and logic to add/update/edit/show triggers
Oct 9, 2024
37547f6
add migration
Oct 9, 2024
28d6248
whitespace
Oct 9, 2024
68f6e75
reformat
Oct 9, 2024
dfc2aeb
Update query snapshots
github-actions[bot] Oct 9, 2024
39bc84d
Update query snapshots
github-actions[bot] Oct 9, 2024
e373715
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 9, 2024
1bc369a
Update query snapshots
github-actions[bot] Oct 9, 2024
3694b17
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 9, 2024
d852322
fix for migration warning
Oct 9, 2024
f931436
fix missing property on tests
Oct 9, 2024
2d140be
format
Oct 9, 2024
cb737c7
add basic activity description
Oct 9, 2024
de18c12
add test
Oct 9, 2024
8f67fba
align property name with sdk
Oct 9, 2024
024689e
format
Oct 14, 2024
e6274cc
format
Oct 14, 2024
73a91e2
Merge branch 'master' into richard/url-trigger
Oct 14, 2024
75d6f04
update migrations
Oct 14, 2024
c7f3471
fix test, also opt in to session replay
Oct 17, 2024
16f5f52
Merge branch 'master' into richard/url-trigger
Oct 17, 2024
91bcd4a
update migration
Oct 17, 2024
eb444b9
spacing, add helper text
Oct 17, 2024
b1928d3
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 17, 2024
c34ed96
hide it behind a feature flag for now
Oct 17, 2024
19e5524
make spacing smaller
Oct 17, 2024
b118452
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 17, 2024
b7dccdc
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 17, 2024
151f668
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 17, 2024
1def1cb
Update `_update_team`
Twixes Oct 17, 2024
05b9423
Merge branch 'master' into richard/url-trigger
Oct 17, 2024
b99c70d
update migrations
Oct 17, 2024
2731c8b
Update query snapshots
github-actions[bot] Oct 17, 2024
5428649
Update UI snapshots for `chromium` (1)
github-actions[bot] Oct 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
richard-better marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
richard-better marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 130 additions & 6 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,7 +12,9 @@ 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'
Expand All @@ -21,13 +24,19 @@ import { PropertySelect } from 'lib/components/PropertySelect/PropertySelect'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { 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 +268,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 +338,121 @@ 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>
richard-better marked this conversation as resolved.
Show resolved Hide resolved
<LemonField name="url" className="flex-1">
<LemonInput autoFocus placeholder="Enter URL" data-attr="url-input" />
</LemonField>
richard-better marked this conversation as resolved.
Show resolved Hide resolved
</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-2 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">
<div className="flex items-center mb-4 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>

{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 +607,7 @@ export function ReplayCostControl(): JSX.Element | null {
</>
)}
<LinkedFlagSelector />
<UrlTriggerOptions />
</>
richard-better marked this conversation as resolved.
Show resolved Hide resolved
</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
Loading