diff --git a/frontend/__snapshots__/components-activitylog--team-activity--dark.png b/frontend/__snapshots__/components-activitylog--team-activity--dark.png index d7bc111b04eb0..69ffefc47369b 100644 Binary files a/frontend/__snapshots__/components-activitylog--team-activity--dark.png and b/frontend/__snapshots__/components-activitylog--team-activity--dark.png differ diff --git a/frontend/__snapshots__/components-activitylog--team-activity--light.png b/frontend/__snapshots__/components-activitylog--team-activity--light.png index 5b48342c45011..0ac59e3276446 100644 Binary files a/frontend/__snapshots__/components-activitylog--team-activity--light.png and b/frontend/__snapshots__/components-activitylog--team-activity--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png index 420e810171c60..e9fe7de459ce9 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png index a5eaddfafa8fc..b31b13bf96316 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png index a078dc1922b9b..557a24ad829de 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png index bb8b99008f083..18eeafe53a294 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png index a078dc1922b9b..557a24ad829de 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png index bb8b99008f083..18eeafe53a294 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png index 92459b7dfc58c..9c3fb1e344f74 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png index 2484205c3d826..e41e7dec7df53 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png index 34aab05bac888..2517ac22b8fc9 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png index 708c88f176b7c..4fce8a837f7a9 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png index 31c6a96ca514a..d9b70116e57b6 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png index 36f040521e52f..88ad89c98e8d9 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png index c750624468474..0dac5dff8a31f 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png index 353ba62db76a5..b6af1c8e969b6 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png index 6106f71c1f62e..7ca69b7425a30 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png index e9d26af168a86..4a9a95e7c36d7 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png index 9d98ab8ea4cfc..657492c654a5d 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png index fd332038ca699..7be9fca3cf6be 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png index 9768e4df833e1..ab975529f0e67 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png index 08eeadb5d8bb5..a4a1c5246525b 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png index 7b480de622209..0cc51fee095f6 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png index f1c8e5cacb387..312e4ac544ef9 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png index 5a1bd8020ef2c..13921a926aa45 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png index 215b67a249262..bca3d330610fc 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector--default--dark.png b/frontend/__snapshots__/components-playerinspector--default--dark.png index 2b57e31f7eee7..3bd75598f3910 100644 Binary files a/frontend/__snapshots__/components-playerinspector--default--dark.png and b/frontend/__snapshots__/components-playerinspector--default--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector--default--light.png b/frontend/__snapshots__/components-playerinspector--default--light.png index f1820f5829033..19dad22b1486b 100644 Binary files a/frontend/__snapshots__/components-playerinspector--default--light.png and b/frontend/__snapshots__/components-playerinspector--default--light.png differ diff --git a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss index b991fcd6121a3..8aac0fa8754b1 100644 --- a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss +++ b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss @@ -104,6 +104,13 @@ } } + &.LemonCheckbox--xsmall { + label { + min-height: 1.625rem; + padding: 0 0.375rem; + } + } + .Field--error & { label { border: 1px solid var(--danger); diff --git a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.tsx b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.tsx index a5a373f1668f8..32b415643b771 100644 --- a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.tsx +++ b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.tsx @@ -18,7 +18,7 @@ export interface LemonCheckboxProps { className?: string labelClassName?: string fullWidth?: boolean - size?: 'small' | 'medium' + size?: 'xsmall' | 'small' | 'medium' bordered?: boolean /** @deprecated See https://github.com/PostHog/posthog/pull/9357#pullrequestreview-933783868. */ color?: string diff --git a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx index f7798f0065378..30793b4ed95f0 100644 --- a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx +++ b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx @@ -109,12 +109,21 @@ export function LemonMenu({ setTimeout(() => itemsRef?.current?.[activeItemIndex]?.current?.scrollIntoView({ block: 'center' }), 0) } }, + // no need to update this when itemsRef changes + // eslint-disable-next-line react-hooks/exhaustive-deps [onVisibilityChange, activeItemIndex] ) return ( } + overlay={ + + } closeOnClickInside referenceRef={referenceRef} onVisibilityChange={_onVisibilityChange} @@ -128,7 +137,7 @@ export interface LemonMenuOverlayProps { tooltipPlacement?: TooltipProps['placement'] itemsRef?: React.RefObject[]> /** @default 'small' */ - buttonSize?: 'small' | 'medium' + buttonSize?: 'xsmall' | 'small' | 'medium' } export function LemonMenuOverlay({ @@ -159,7 +168,7 @@ export function LemonMenuOverlay({ interface LemonMenuSectionListProps { sections: LemonMenuSection[] - buttonSize: 'small' | 'medium' + buttonSize: 'xsmall' | 'small' | 'medium' tooltipPlacement: TooltipProps['placement'] | undefined itemsRef: React.RefObject[]> | undefined } @@ -208,7 +217,7 @@ export function LemonMenuSectionList({ interface LemonMenuItemListProps { items: LemonMenuItem[] - buttonSize: 'small' | 'medium' + buttonSize: 'xsmall' | 'small' | 'medium' tooltipPlacement: TooltipProps['placement'] | undefined itemsRef: React.RefObject[]> | undefined itemIndexOffset?: number @@ -241,7 +250,7 @@ export function LemonMenuItemList({ interface LemonMenuItemButtonProps { item: LemonMenuItem - size: 'small' | 'medium' + size: 'xsmall' | 'small' | 'medium' tooltipPlacement: TooltipProps['placement'] | undefined } @@ -280,8 +289,8 @@ const LemonMenuItemButton: FunctionComponent {button} diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index 56641122280df..fb7ac59b7166f 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -501,6 +501,7 @@ export const humanFriendlyMilliseconds = (timestamp: number | undefined): string return `${(timestamp / 1000).toFixed(2)}s` } + export function humanFriendlyDuration( d: string | number | null | undefined, { diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index de0a796582003..bc0014c36ad88 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -18,6 +18,8 @@ import { Holdout } from 'scenes/experiments/holdoutsLogic' import { isFilterWithDisplay, isFunnelsFilter, isTrendsFilter } from 'scenes/insights/sharedUtils' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { EventIndex } from 'scenes/session-recordings/player/eventIndex' +import { MiniFilterKey } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' +import { InspectorListItemType } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' import { filtersFromUniversalFilterGroups } from 'scenes/session-recordings/utils' import { NewSurvey, SurveyTemplateType } from 'scenes/surveys/constants' import { userLogic } from 'scenes/userLogic' @@ -63,7 +65,6 @@ import { RecordingUniversalFilters, Resource, SessionPlayerData, - SessionRecordingPlayerTab, SessionRecordingType, SessionRecordingUsageType, Survey, @@ -442,11 +443,10 @@ export const eventUsageLogic = kea([ reportRecordingPlayerSeekbarEventHovered: true, reportRecordingPlayerSpeedChanged: (newSpeed: number) => ({ newSpeed }), reportRecordingPlayerSkipInactivityToggled: (skipInactivity: boolean) => ({ skipInactivity }), - reportRecordingInspectorTabViewed: (tab: SessionRecordingPlayerTab) => ({ tab }), - reportRecordingInspectorItemExpanded: (tab: SessionRecordingPlayerTab, index: number) => ({ tab, index }), - reportRecordingInspectorMiniFilterViewed: (tab: SessionRecordingPlayerTab, minifilterKey: string) => ({ - tab, + reportRecordingInspectorItemExpanded: (tab: InspectorListItemType, index: number) => ({ tab, index }), + reportRecordingInspectorMiniFilterViewed: (minifilterKey: MiniFilterKey, enabled: boolean) => ({ minifilterKey, + enabled, }), reportNextRecordingTriggered: (automatic: boolean) => ({ automatic, @@ -954,14 +954,11 @@ export const eventUsageLogic = kea([ reportRecordingPlayerSkipInactivityToggled: ({ skipInactivity }) => { posthog.capture('recording player skip inactivity toggled', { skip_inactivity: skipInactivity }) }, - reportRecordingInspectorTabViewed: ({ tab }) => { - posthog.capture('recording inspector tab viewed', { tab }) - }, reportRecordingInspectorItemExpanded: ({ tab, index }) => { - posthog.capture('recording inspector item expanded', { tab, index }) + posthog.capture('recording inspector item expanded', { tab: 'replay-4000', type: tab, index }) }, - reportRecordingInspectorMiniFilterViewed: ({ tab, minifilterKey }) => { - posthog.capture('recording inspector minifilter selected', { tab, minifilterKey }) + reportRecordingInspectorMiniFilterViewed: ({ minifilterKey, enabled }) => { + posthog.capture('recording inspector minifilter selected', { tab: 'replay-4000', enabled, minifilterKey }) }, reportNextRecordingTriggered: ({ automatic }) => { posthog.capture('recording next recording triggered', { automatic }) diff --git a/frontend/src/scenes/session-recordings/apm/components/PerformanceCard.tsx b/frontend/src/scenes/session-recordings/apm/components/PerformanceCard.tsx index 2398ae422089f..b4bb00e1ca7e8 100644 --- a/frontend/src/scenes/session-recordings/apm/components/PerformanceCard.tsx +++ b/frontend/src/scenes/session-recordings/apm/components/PerformanceCard.tsx @@ -193,18 +193,21 @@ function itemToPerformanceValues(item: PerformanceEvent): { export function PerformanceCardRow({ item }: { item: PerformanceEvent }): JSX.Element { const performanceValues = itemToPerformanceValues(item) - return ( - {Object.entries(summaryMapping).map(([key, summary]) => ( - - - - ))} + {Object.entries(summaryMapping) + .filter(([key]) => performanceValues[key] !== undefined) + .map(([key, summary]) => { + return ( + + + + ) + })} ) } @@ -219,15 +222,17 @@ export function PerformanceCardDescriptions({ const performanceValues = itemToPerformanceValues(item) return (
- {Object.entries(summaryMapping).map(([key, summary]) => ( - - ))} + {Object.entries(summaryMapping) + .filter(([key]) => performanceValues[key] !== undefined) + .map(([key, summary]) => ( + + ))}
) } diff --git a/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts b/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts index 9a83f692f4066..2e145aabd578e 100644 --- a/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts +++ b/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts @@ -4,19 +4,18 @@ import { initiatorToAssetTypeMapping, itemSizeInfo, } from 'scenes/session-recordings/apm/performance-event-utils' -import { miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' import { InspectorListItemBase } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' import { sessionRecordingDataLogic, SessionRecordingDataLogicProps, } from 'scenes/session-recordings/player/sessionRecordingDataLogic' -import { PerformanceEvent, RecordingEventType, SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes, PerformanceEvent, RecordingEventType } from '~/types' import type { performanceEventDataLogicType } from './performanceEventDataLogicType' export type InspectorListItemPerformance = InspectorListItemBase & { - type: SessionRecordingPlayerTab.NETWORK + type: FilterableInspectorListItemTypes.NETWORK data: PerformanceEvent } @@ -105,12 +104,7 @@ export const performanceEventDataLogic = kea([ key((props: PerformanceEventDataLogicProps) => `${props.key}-${props.sessionRecordingId}`), connect((props: PerformanceEventDataLogicProps) => ({ actions: [], - values: [ - miniFiltersLogic, - ['showOnlyMatching', 'tab', 'miniFiltersByKey', 'searchQuery'], - sessionRecordingDataLogic(props), - ['sessionPlayerData', 'webVitalsEvents'], - ], + values: [sessionRecordingDataLogic(props), ['sessionPlayerData', 'webVitalsEvents']], })), selectors(() => ({ allPerformanceEvents: [ diff --git a/frontend/src/scenes/session-recordings/components/PanelSettings.tsx b/frontend/src/scenes/session-recordings/components/PanelSettings.tsx new file mode 100644 index 0000000000000..2e1a11809383a --- /dev/null +++ b/frontend/src/scenes/session-recordings/components/PanelSettings.tsx @@ -0,0 +1,63 @@ +import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' +import { LemonMenu, LemonMenuItem, LemonMenuProps } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { Tooltip } from 'lib/lemon-ui/Tooltip' + +/** + * TODO the lemon button font only has 700 and 800 weights available. + * Ideally these buttons would use more like 400 and 500 weights. + * or even 300 and 400 weights. + * when inactive / active respectively. + */ + +interface SettingsMenuProps extends Omit { + label: string + items: LemonMenuItem[] + icon: JSX.Element + isAvailable?: boolean + whenUnavailable?: LemonMenuItem +} + +export function SettingsMenu({ + label, + items, + icon, + isAvailable = true, + whenUnavailable, + ...props +}: SettingsMenuProps): JSX.Element { + const active = items.some((cf) => !!cf.active) + return ( + + + {label} + + + ) +} + +export function SettingsToggle({ + title, + icon, + label, + active, + ...props +}: Omit & { + active: boolean + title: string + icon?: JSX.Element | null + label: JSX.Element | string +}): JSX.Element { + const button = ( + + {label} + + ) + + // otherwise the tooltip shows instead of the disabled reason + return props.disabledReason ? button : {button} +} diff --git a/frontend/src/scenes/session-recordings/player/PlayerSidebar.tsx b/frontend/src/scenes/session-recordings/player/PlayerSidebar.tsx index eaa19d22260c2..315330a437704 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerSidebar.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerSidebar.tsx @@ -79,6 +79,7 @@ export function PlayerSidebar(): JSX.Element { label: capitalizeFirstLetter(tabId), }))} barClassName="mb-0" + size="small" />
diff --git a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspector.tsx b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspector.tsx index dc8c712cef413..ec00c7285f6b8 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspector.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspector.tsx @@ -1,3 +1,4 @@ +import { PlayerInspectorBottomSettings } from 'scenes/session-recordings/player/inspector/PlayerInspectorBottomSettings' import { PlayerInspectorControls } from 'scenes/session-recordings/player/inspector/PlayerInspectorControls' import { PlayerInspectorList } from 'scenes/session-recordings/player/inspector/PlayerInspectorList' @@ -6,6 +7,7 @@ export function PlayerInspector(): JSX.Element { <> + ) } diff --git a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorBottomSettings.tsx b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorBottomSettings.tsx new file mode 100644 index 0000000000000..156798a8eaacc --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorBottomSettings.tsx @@ -0,0 +1,109 @@ +import './PlayerInspectorList.scss' + +import { useActions, useValues } from 'kea' +import { userPreferencesLogic } from 'lib/logic/userPreferencesLogic' +import { SettingsToggle } from 'scenes/session-recordings/components/PanelSettings' +import { miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' + +import { FilterableInspectorListItemTypes } from '~/types' + +import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' +import { playerInspectorLogic } from './playerInspectorLogic' + +function HideProperties(): JSX.Element | null { + const { logicProps } = useValues(sessionRecordingPlayerLogic) + const inspectorLogic = playerInspectorLogic(logicProps) + + const { allItemsByItemType } = useValues(inspectorLogic) + + const { miniFiltersForType } = useValues(miniFiltersLogic) + const { hidePostHogPropertiesInTable } = useValues(userPreferencesLogic) + const { setHidePostHogPropertiesInTable } = useActions(userPreferencesLogic) + + const hasEventsFiltersSelected = miniFiltersForType(FilterableInspectorListItemTypes.EVENTS).some((x) => x.enabled) + const hasEventsToDisplay = allItemsByItemType[FilterableInspectorListItemTypes.EVENTS]?.length > 0 + + return ( + setHidePostHogPropertiesInTable(!hidePostHogPropertiesInTable)} + disabledReason={ + hasEventsToDisplay && hasEventsFiltersSelected ? undefined : 'There are no events in the list' + } + active={hidePostHogPropertiesInTable} + /> + ) +} + +function SyncScrolling(): JSX.Element { + const { logicProps } = useValues(sessionRecordingPlayerLogic) + const inspectorLogic = playerInspectorLogic(logicProps) + + const { syncScrollPaused } = useValues(inspectorLogic) + const { setSyncScrollPaused } = useActions(inspectorLogic) + + return ( + { + setSyncScrollPaused(!syncScrollPaused) + }} + active={!syncScrollPaused} + /> + ) +} + +function ShowOnlyMatching(): JSX.Element { + const { logicProps } = useValues(sessionRecordingPlayerLogic) + const inspectorLogic = playerInspectorLogic(logicProps) + + const { allItemsByItemType, allowMatchingEventsFilter } = useValues(inspectorLogic) + + const { showOnlyMatching, miniFiltersForType } = useValues(miniFiltersLogic) + const { setShowOnlyMatching } = useActions(miniFiltersLogic) + + const hasEventsFiltersSelected = miniFiltersForType(FilterableInspectorListItemTypes.EVENTS).some((x) => x.enabled) + const hasEventsToDisplay = allItemsByItemType[FilterableInspectorListItemTypes.EVENTS]?.length > 0 + + return ( + { + setShowOnlyMatching(!showOnlyMatching) + }} + disabledReason={ + hasEventsToDisplay && hasEventsFiltersSelected + ? allowMatchingEventsFilter + ? undefined + : 'There are no event filters to match against' + : 'There are no events in the list' + } + /> + ) +} + +export function PlayerInspectorBottomSettings(): JSX.Element { + return ( +
+ + + +
+ ) +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorControls.tsx b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorControls.tsx index 33580964c1db0..f27a9281ab9d2 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorControls.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorControls.tsx @@ -1,226 +1,174 @@ -import { IconBug, IconDashboard, IconInfo, IconTerminal } from '@posthog/icons' -import { LemonButton, LemonCheckbox, LemonInput, LemonSelect, LemonTabs, Tooltip } from '@posthog/lemon-ui' +import { + BaseIcon, + IconBug, + IconCheck, + IconDashboard, + IconGear, + IconInfo, + IconSearch, + IconTerminal, +} from '@posthog/icons' +import { LemonButton, LemonInput, LemonMenuItem, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { FEATURE_FLAGS } from 'lib/constants' import { IconUnverifiedEvent } from 'lib/lemon-ui/icons' -import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { userPreferencesLogic } from 'lib/logic/userPreferencesLogic' import { capitalizeFirstLetter } from 'lib/utils' -import { IconWindow } from 'scenes/session-recordings/player/icons' -import { miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' +import { useEffect, useState } from 'react' +import { SettingsMenu, SettingsToggle } from 'scenes/session-recordings/components/PanelSettings' +import { miniFiltersLogic, SharedListMiniFilter } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' +import { playerInspectorLogic } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' +import { teamLogic } from 'scenes/teamLogic' -import { SessionRecordingPlayerTab } from '~/types' +import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic' +import { FilterableInspectorListItemTypes } from '~/types' -import { - sessionRecordingPlayerLogic, - SessionRecordingPlayerLogicProps, - SessionRecordingPlayerMode, -} from '../sessionRecordingPlayerLogic' +import { sessionRecordingPlayerLogic, SessionRecordingPlayerMode } from '../sessionRecordingPlayerLogic' import { InspectorSearchInfo } from './components/InspectorSearchInfo' -import { playerInspectorLogic } from './playerInspectorLogic' - -function HideProperties(): JSX.Element | null { - const { logicProps } = useValues(sessionRecordingPlayerLogic) - const inspectorLogic = playerInspectorLogic(logicProps) - const { tab } = useValues(inspectorLogic) - const { hidePostHogPropertiesInTable } = useValues(userPreferencesLogic) - const { setHidePostHogPropertiesInTable } = useActions(userPreferencesLogic) - - return tab === SessionRecordingPlayerTab.EVENTS ? ( - - ) : null -} - -function MiniFilters(): JSX.Element { - const { miniFilters } = useValues(miniFiltersLogic) - const { setMiniFilter } = useActions(miniFiltersLogic) - - return ( -
- {miniFilters.map((filter) => ( - { - // "alone" should always be a select-to-true action - setMiniFilter(filter.key, filter.alone || !filter.enabled) - }} - tooltip={filter.tooltip} - > - {filter.name} - - ))} -
- ) -} - -function WindowSelector(): JSX.Element { - const { logicProps } = useValues(sessionRecordingPlayerLogic) - const inspectorLogic = playerInspectorLogic(logicProps) - const { windowIdFilter, windowIds } = useValues(inspectorLogic) - const { setWindowIdFilter } = useActions(inspectorLogic) - - return windowIds.length > 1 ? ( - setWindowIdFilter(val || null)} - options={[ - { - value: null, - label: 'All windows', - icon: , - }, - ...windowIds.map((windowId, index) => ({ - value: windowId, - label: `Window ${index + 1}`, - icon: , - })), - ]} - tooltip="Each recording window translates to a distinct browser tab or window." - /> - ) : ( - // returns an empty div to keep spacing/positioning consistent -
- ) -} export const TabToIcon = { - [SessionRecordingPlayerTab.ALL]: undefined, - [SessionRecordingPlayerTab.EVENTS]: IconUnverifiedEvent, - [SessionRecordingPlayerTab.CONSOLE]: IconTerminal, - [SessionRecordingPlayerTab.NETWORK]: IconDashboard, - [SessionRecordingPlayerTab.DOCTOR]: IconBug, -} - -function TabButtons({ - tabs, - logicProps, -}: { - tabs: SessionRecordingPlayerTab[] - logicProps: SessionRecordingPlayerLogicProps -}): JSX.Element { - const inspectorLogic = playerInspectorLogic(logicProps) - const { tab, tabsState } = useValues(inspectorLogic) - const { setTab } = useActions(inspectorLogic) - - return ( - setTab(tabId)} - tabs={tabs.map((tabId) => { - const TabIcon = TabToIcon[tabId] - return { - key: tabId, - label: ( -
- {TabIcon ? ( - tabsState[tabId] === 'loading' ? ( - - ) : ( - - ) - ) : undefined} - {capitalizeFirstLetter(tabId)} -
- ), - } - })} - /> - ) + [FilterableInspectorListItemTypes.EVENTS]: IconUnverifiedEvent, + [FilterableInspectorListItemTypes.CONSOLE]: IconTerminal, + [FilterableInspectorListItemTypes.NETWORK]: IconDashboard, + [FilterableInspectorListItemTypes.DOCTOR]: IconBug, } export function PlayerInspectorControls(): JSX.Element { const { logicProps } = useValues(sessionRecordingPlayerLogic) - const inspectorLogic = playerInspectorLogic(logicProps) - const { tab, showMatchingEventsFilter } = useValues(inspectorLogic) - const { setTab } = useActions(inspectorLogic) - const { showOnlyMatching, searchQuery } = useValues(miniFiltersLogic) - const { setShowOnlyMatching, setSearchQuery } = useActions(miniFiltersLogic) + const { allItemsByMiniFilterKey, allItemsByItemType } = useValues(playerInspectorLogic(logicProps)) + const { searchQuery, miniFiltersForType, miniFiltersByKey } = useValues(miniFiltersLogic) + const { setSearchQuery, setMiniFilter } = useActions(miniFiltersLogic) + const { currentTeam } = useValues(teamLogic) + const { openSettingsPanel } = useActions(sidePanelSettingsLogic) const mode = logicProps.mode ?? SessionRecordingPlayerMode.Standard const { featureFlags } = useValues(featureFlagLogic) - const inspectorTabs = [ - SessionRecordingPlayerTab.ALL, - SessionRecordingPlayerTab.EVENTS, - SessionRecordingPlayerTab.CONSOLE, - SessionRecordingPlayerTab.NETWORK, - ] - if (window.IMPERSONATED_SESSION || featureFlags[FEATURE_FLAGS.SESSION_REPLAY_DOCTOR]) { - inspectorTabs.push(SessionRecordingPlayerTab.DOCTOR) - } else { - // ensure we've not left the doctor tab in the tabs state - if (tab === SessionRecordingPlayerTab.DOCTOR) { - setTab(SessionRecordingPlayerTab.ALL) + const [showSearch, setShowSearch] = useState(false) + + useEffect(() => { + if (!window.IMPERSONATED_SESSION && !featureFlags[FEATURE_FLAGS.SESSION_REPLAY_DOCTOR]) { + // ensure we've not left the doctor active + setMiniFilter('doctor', false) } + }, []) + + function filterMenuForType(type: FilterableInspectorListItemTypes): LemonMenuItem[] { + return miniFiltersForType(type) + ?.filter((x) => x.name !== 'All') + .map( + // without setting fontVariant to none a single digit number between brackets gets rendered as a ligature 🤷 + (filter: SharedListMiniFilter) => + ({ + label: ( +
+ {filter.name}  + + ({allItemsByMiniFilterKey[filter.key]?.length ?? 0}) + +
+ ), + icon: filter.enabled ? : , + status: filter.enabled ? 'danger' : 'default', + onClick: () => { + setMiniFilter(filter.key, !filter.enabled) + }, + tooltip: filter.tooltip, + active: filter.enabled, + } satisfies LemonMenuItem) + ) } - if (mode === SessionRecordingPlayerMode.Sharing) { - // Events can't be loaded in sharing mode - inspectorTabs.splice(1, 1) - // Doctor tab is not available in sharing mode - inspectorTabs.pop() - } + const eventsFilters: LemonMenuItem[] = filterMenuForType(FilterableInspectorListItemTypes.EVENTS) + const consoleFilters: LemonMenuItem[] = filterMenuForType(FilterableInspectorListItemTypes.CONSOLE) + const hasConsoleItems = allItemsByItemType[FilterableInspectorListItemTypes.CONSOLE]?.length > 0 + const networkFilters: LemonMenuItem[] = filterMenuForType(FilterableInspectorListItemTypes.NETWORK) + const hasNetworkItems = allItemsByItemType[FilterableInspectorListItemTypes.NETWORK]?.length > 0 return ( -
+
-
- -
-
- -
- setSearchQuery(e)} - placeholder="Search..." - type="search" - value={searchQuery} - fullWidth - className="min-w-60" - suffix={ - }> - - - } - /> - - - -
- - +
+ {mode !== SessionRecordingPlayerMode.Sharing && ( + } + /> + )} + } + isAvailable={hasConsoleItems || !!currentTeam?.capture_console_log_opt_in} + whenUnavailable={{ + label:

Configure console log capture in settings.

, + onClick: () => openSettingsPanel({ sectionId: 'project-replay', settingId: 'replay' }), + icon: , + }} + /> + } + isAvailable={hasNetworkItems || !!currentTeam?.capture_performance_opt_in} + whenUnavailable={{ + label:

Configure network capture in settings.

, + onClick: () => + openSettingsPanel({ sectionId: 'project-replay', settingId: 'replay-network' }), + icon: , + }} + /> + {(window.IMPERSONATED_SESSION || featureFlags[FEATURE_FLAGS.SESSION_REPLAY_DOCTOR]) && + mode !== SessionRecordingPlayerMode.Sharing && ( + } + label="Doctor" + active={!!miniFiltersByKey['doctor']?.enabled} + onClick={() => setMiniFilter('doctor', !miniFiltersByKey['doctor']?.enabled)} + /> + )} + } + size="xsmall" + onClick={() => { + const newState = !showSearch + setShowSearch(newState) + if (!newState) { + // clear the search when we're hiding the search bar + setSearchQuery('') + } + }} + status={showSearch ? 'danger' : 'default'} + title="Search" + />
+
- {showMatchingEventsFilter ? ( -
- - - Only events matching filters - + {showSearch && ( +
+ setSearchQuery(e)} + placeholder="Search..." + type="search" + value={searchQuery} + fullWidth + className="min-w-60" + suffix={ + }> - -
- ) : null} -
+ } + /> +
+ )}
) } diff --git a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorList.tsx b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorList.tsx index cc4cb7b797153..63b6a78ce7d02 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorList.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/PlayerInspectorList.tsx @@ -1,119 +1,26 @@ import './PlayerInspectorList.scss' -import { LemonButton, Link } from '@posthog/lemon-ui' import { range } from 'd3' import { useActions, useValues } from 'kea' -import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { useEffect, useMemo, useRef } from 'react' import AutoSizer from 'react-virtualized/dist/es/AutoSizer' import { CellMeasurer, CellMeasurerCache } from 'react-virtualized/dist/es/CellMeasurer' import { List, ListRowRenderer } from 'react-virtualized/dist/es/List' -import { teamLogic } from 'scenes/teamLogic' -import { userLogic } from 'scenes/userLogic' -import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic' -import { AvailableFeature, SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes } from '~/types' import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { PlayerInspectorListItem } from './components/PlayerInspectorListItem' import { playerInspectorLogic } from './playerInspectorLogic' -function isLocalhost(url: string | null | undefined): boolean { - try { - return !!url && ['localhost', '127.0.0.1'].includes(new URL(url).hostname) - } catch (e) { - // for e.g. mobile doesn't have a URL, so we can swallow this and move on - return false - } -} - -function EmptyNetworkTab({ - captureNetworkLogOptIn, - captureNetworkFeatureAvailable, - recordingURL, -}: { - captureNetworkLogOptIn: boolean - captureNetworkFeatureAvailable: boolean - recordingURL: string | null | undefined -}): JSX.Element { - const { openSettingsPanel } = useActions(sidePanelSettingsLogic) - return !captureNetworkFeatureAvailable ? ( -
- -
- ) : !captureNetworkLogOptIn ? ( - <> -
-

Performance events

-

- Capture performance events like network requests during the browser recording to understand things - like response times, page load times, and more. -

- openSettingsPanel({ sectionId: 'project-replay' })} - targetBlank - > - Configure in settings - -
- - ) : isLocalhost(recordingURL) ? ( - <> -
-

Network recording

-

- Network capture is not supported when replay is running on localhost.{' '} - Learn more in our docs . -

-
- - ) : ( - <>No results found in this recording. - ) -} - -function EmptyConsoleTab({ captureConsoleLogOptIn }: { captureConsoleLogOptIn: boolean }): JSX.Element { - const { openSettingsPanel } = useActions(sidePanelSettingsLogic) - - return captureConsoleLogOptIn ? ( - <>No results found in this recording. - ) : ( - <> -
-

Console logs

-

- Capture all console logs during the browser recording to get technical information on what was - occurring. -

- openSettingsPanel({ sectionId: 'project-replay' })} - targetBlank - > - Configure in settings - -
- - ) -} - export function PlayerInspectorList(): JSX.Element { - const { logicProps, snapshotsLoaded, sessionPlayerMetaData } = useValues(sessionRecordingPlayerLogic) + const { logicProps, snapshotsLoaded } = useValues(sessionRecordingPlayerLogic) const inspectorLogic = playerInspectorLogic(logicProps) - const { items, tabsState, playbackIndicatorIndex, playbackIndicatorIndexStop, syncScrollPaused, tab } = + const { items, inspectorDataState, playbackIndicatorIndex, playbackIndicatorIndexStop, syncScrollPaused } = useValues(inspectorLogic) const { setSyncScrollPaused } = useActions(inspectorLogic) - const { currentTeam } = useValues(teamLogic) - const { hasAvailableFeature } = useValues(userLogic) - const performanceAvailable: boolean = hasAvailableFeature(AvailableFeature.RECORDINGS_PERFORMANCE) - const performanceEnabled: boolean = currentTeam?.capture_performance_opt_in ?? false const cellMeasurerCache = useMemo( () => @@ -217,45 +124,19 @@ export function PlayerInspectorList(): JSX.Element { /> )} - {syncScrollPaused && ( -
- { - if (listRef.current) { - listRef.current.scrollToRow(playbackIndicatorIndex) - } - // Tricky: Need to dely to make sure the row scrolled has finished - setTimeout(() => setSyncScrollPaused(false), 100) - }} - > - Sync scrolling - -
- )}
- ) : tabsState[tab] === 'loading' ? ( + ) : inspectorDataState[FilterableInspectorListItemTypes.EVENTS] === 'loading' || + inspectorDataState[FilterableInspectorListItemTypes.CONSOLE] === 'loading' || + inspectorDataState[FilterableInspectorListItemTypes.NETWORK] === 'loading' ? (
- ) : tabsState[tab] === 'ready' ? ( + ) : inspectorDataState[FilterableInspectorListItemTypes.EVENTS] === 'ready' || + inspectorDataState[FilterableInspectorListItemTypes.CONSOLE] === 'ready' || + inspectorDataState[FilterableInspectorListItemTypes.NETWORK] === 'ready' ? ( // If we are "ready" but with no results this must mean some results are filtered out
No results matching your filters.
- ) : ( -
- {tab === SessionRecordingPlayerTab.CONSOLE ? ( - - ) : tab === SessionRecordingPlayerTab.NETWORK ? ( - - ) : ( - 'No results found in this recording.' - )} -
- )} + ) : null}
) } diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemConsoleLog.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemConsoleLog.tsx index cf0160efacf46..29a0d879af8d4 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemConsoleLog.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemConsoleLog.tsx @@ -28,8 +28,6 @@ export function ItemConsoleLog({ item }: ItemConsoleLogProps): JSX.Element { export function ItemConsoleLogDetail({ item }: ItemConsoleLogProps): JSX.Element { return (
-
{item.data.content}
-
{(item.data.count || 1) > 1 ? ( <> diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.stories.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.stories.tsx index 3df6ef3c23ca5..7f26a3c0eee43 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.stories.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.stories.tsx @@ -9,7 +9,7 @@ import { import { InspectorListItemEvent } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' import { mswDecorator } from '~/mocks/browser' -import { RecordingEventType, SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes, RecordingEventType } from '~/types' type Story = StoryObj const meta: Meta = { @@ -47,7 +47,7 @@ function makeItem( search: '', timeInRecording: 0, timestamp: now(), - type: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, ...itemOverrides, } } diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemInactivity.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemInactivity.tsx new file mode 100644 index 0000000000000..dc86de7549dd4 --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemInactivity.tsx @@ -0,0 +1,19 @@ +import { IconClock } from '@posthog/icons' +import { LemonDivider } from 'lib/lemon-ui/LemonDivider' +import { humanFriendlyDuration } from 'lib/utils' +import { InspectorListItemInactivity } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' + +export function ItemInactivity({ item }: { item: InspectorListItemInactivity }): JSX.Element { + return ( +
+ +
+ +
+ {humanFriendlyDuration(item.durationMs / 1000)} of inactivity +
+
+ +
+ ) +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemSummary.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemSummary.tsx new file mode 100644 index 0000000000000..8a23e605187a5 --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemSummary.tsx @@ -0,0 +1,32 @@ +import { IconCursor, IconKeyboard, IconWarning } from '@posthog/icons' +import clsx from 'clsx' +import { pluralize } from 'lib/utils' + +import { InspectorListItemSummary } from '../playerInspectorLogic' + +export function ItemSummary({ item }: { item: InspectorListItemSummary }): JSX.Element { + return ( +
+
+ + {pluralize(item.clickCount || 0, 'click')} +
+
+ + {pluralize(item.keypressCount || 0, 'keystroke')} +
+
0 ? 'text-danger' : 'text-success' + )} + > + + {pluralize(item.errorCount || 0, 'error')} +
+
+ ) +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/PlayerInspectorListItem.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/PlayerInspectorListItem.tsx index 4fc248da2c70e..b280fa4c58763 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/PlayerInspectorListItem.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/PlayerInspectorListItem.tsx @@ -1,4 +1,12 @@ -import { IconDashboard, IconEye, IconGear, IconMinusSquare, IconPlusSquare, IconTerminal } from '@posthog/icons' +import { + BaseIcon, + IconDashboard, + IconEye, + IconGear, + IconMinusSquare, + IconPlusSquare, + IconTerminal, +} from '@posthog/icons' import { LemonButton, LemonDivider } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' @@ -9,10 +17,12 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { ceilMsToClosestSecond, colonDelimitedDuration } from 'lib/utils' import { useEffect, useRef } from 'react' import { ItemComment, ItemCommentDetail } from 'scenes/session-recordings/player/inspector/components/ItemComment' +import { ItemInactivity } from 'scenes/session-recordings/player/inspector/components/ItemInactivity' +import { ItemSummary } from 'scenes/session-recordings/player/inspector/components/ItemSummary' import { useDebouncedCallback } from 'use-debounce' import useResizeObserver from 'use-resize-observer' -import { SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes } from '~/types' import { ItemPerformanceEvent, ItemPerformanceEventDetail } from '../../../apm/playerInspector/ItemPerformanceEvent' import { IconWindow } from '../../icons' @@ -23,20 +33,18 @@ import { ItemConsoleLog, ItemConsoleLogDetail } from './ItemConsoleLog' import { ItemDoctor, ItemDoctorDetail } from './ItemDoctor' import { ItemEvent, ItemEventDetail } from './ItemEvent' +const PLAYER_INSPECTOR_LIST_ITEM_MARGIN = 1 + const typeToIconAndDescription = { - [SessionRecordingPlayerTab.ALL]: { - Icon: undefined, - tooltip: 'All events', - }, - [SessionRecordingPlayerTab.EVENTS]: { + [FilterableInspectorListItemTypes.EVENTS]: { Icon: undefined, tooltip: 'Recording event', }, - [SessionRecordingPlayerTab.CONSOLE]: { + [FilterableInspectorListItemTypes.CONSOLE]: { Icon: IconTerminal, tooltip: 'Console log', }, - [SessionRecordingPlayerTab.NETWORK]: { + [FilterableInspectorListItemTypes.NETWORK]: { Icon: IconDashboard, tooltip: 'Network event', }, @@ -60,8 +68,15 @@ const typeToIconAndDescription = { Icon: IconComment, tooltip: 'A user commented on this timestamp in the recording', }, + ['inspector-summary']: { + Icon: undefined, + tooltip: undefined, + }, + ['inactivity']: { + Icon: undefined, + tooltip: undefined, + }, } -const PLAYER_INSPECTOR_LIST_ITEM_MARGIN = 1 function ItemTimeDisplay({ item }: { item: InspectorListItem }): JSX.Element { const { timestampFormat } = useValues(playerSettingsLogic) @@ -71,7 +86,7 @@ function ItemTimeDisplay({ item }: { item: InspectorListItem }): JSX.Element { const fixedUnits = durationMs / 1000 > 3600 ? 3 : 2 return ( - + {timestampFormat != TimestampFormat.Relative ? ( (timestampFormat === TimestampFormat.UTC ? item.timestamp.tz('UTC') : item.timestamp).format( 'DD, MMM HH:mm:ss' @@ -97,35 +112,34 @@ function ItemTimeDisplay({ item }: { item: InspectorListItem }): JSX.Element { function RowItemTitle({ item, finalTimestamp, - showIcon, }: { item: InspectorListItem finalTimestamp: Dayjs | null - showIcon?: boolean }): JSX.Element { - const TypeIcon = typeToIconAndDescription[item.type].Icon - return ( -
- {showIcon && TypeIcon ? : null} - {item.type === SessionRecordingPlayerTab.NETWORK ? ( +
+ {item.type === FilterableInspectorListItemTypes.NETWORK ? ( - ) : item.type === SessionRecordingPlayerTab.CONSOLE ? ( + ) : item.type === FilterableInspectorListItemTypes.CONSOLE ? ( - ) : item.type === SessionRecordingPlayerTab.EVENTS ? ( + ) : item.type === FilterableInspectorListItemTypes.EVENTS ? ( ) : item.type === 'offline-status' ? ( -
+
{item.offline ? 'Browser went offline' : 'Browser returned online'}
) : item.type === 'browser-visibility' ? ( -
+
Window became {item.status}
- ) : item.type === SessionRecordingPlayerTab.DOCTOR ? ( + ) : item.type === FilterableInspectorListItemTypes.DOCTOR ? ( ) : item.type === 'comment' ? ( + ) : item.type === 'inspector-summary' ? ( + + ) : item.type === 'inactivity' ? ( + ) : null}
) @@ -142,14 +156,14 @@ function RowItemDetail({ }): JSX.Element | null { return (
- {item.type === SessionRecordingPlayerTab.NETWORK ? ( + {item.type === FilterableInspectorListItemTypes.NETWORK ? ( - ) : item.type === SessionRecordingPlayerTab.CONSOLE ? ( + ) : item.type === FilterableInspectorListItemTypes.CONSOLE ? ( - ) : item.type === SessionRecordingPlayerTab.EVENTS ? ( + ) : item.type === FilterableInspectorListItemTypes.EVENTS ? ( ) : item.type === 'offline-status' ? null : item.type === 'browser-visibility' ? null : item.type === - SessionRecordingPlayerTab.DOCTOR ? ( + FilterableInspectorListItemTypes.DOCTOR ? ( ) : item.type === 'comment' ? ( @@ -172,11 +186,9 @@ export function PlayerInspectorListItem({ const { logicProps } = useValues(sessionRecordingPlayerLogic) const { seekToTime } = useActions(sessionRecordingPlayerLogic) - const { tab, end, expandedItems } = useValues(playerInspectorLogic(logicProps)) + const { end, expandedItems } = useValues(playerInspectorLogic(logicProps)) const { setItemExpanded } = useActions(playerInspectorLogic(logicProps)) - const showIcon = tab === SessionRecordingPlayerTab.ALL - const isExpanded = expandedItems.includes(index) // NOTE: We offset by 1 second so that the playback starts just before the event occurs. @@ -215,6 +227,8 @@ export function PlayerInspectorListItem({ const isHovering = useIsHovering(hoverRef) + const TypeIcon = typeToIconAndDescription[item.type].Icon + return (
) : null} - + {item.type !== 'inspector-summary' && item.type !== 'inactivity' && } + + {TypeIcon ? : }
- +
- : } - size="small" - noPadding - onClick={() => setItemExpanded(index, !isExpanded)} - data-attr="expand-inspector-row" - disabledReason={ - item.type === 'offline-status' || item.type === 'browser-visibility' - ? 'This event type does not have a detail view' - : undefined - } - /> + {item.type !== 'inspector-summary' && item.type !== 'inactivity' && ( + : } + size="small" + noPadding + onClick={() => setItemExpanded(index, !isExpanded)} + data-attr="expand-inspector-row" + disabledReason={ + item.type === 'offline-status' || item.type === 'browser-visibility' + ? 'This event type does not have a detail view' + : undefined + } + /> + )}
{isExpanded ? ( diff --git a/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.test.ts b/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.test.ts index 409535ef946b5..05858ffe5eae5 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.test.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.test.ts @@ -2,181 +2,138 @@ import { filterInspectorListItems } from 'scenes/session-recordings/player/inspe import { SharedListMiniFilter } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' import { InspectorListBrowserVisibility, + InspectorListItemComment, InspectorListItemDoctor, InspectorListItemEvent, InspectorListOfflineStatusChange, } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' -import { PerformanceEvent, SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes, PerformanceEvent } from '~/types' describe('filtering inspector list items', () => { - describe('the all tab', () => { - it('includes browser visibility', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: 'browser-visibility', - } as InspectorListBrowserVisibility, - ], - tab: SessionRecordingPlayerTab.ALL, - miniFiltersByKey: { 'all-everything': { enabled: true } as unknown as SharedListMiniFilter }, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(1) - }) - - it('hides doctor items in everything mode', () => { - const filteredItems = filterInspectorListItems({ + it('hides context events when no other events', () => { + expect( + filterInspectorListItems({ allItems: [ { type: 'browser-visibility', } as InspectorListBrowserVisibility, { - type: 'doctor', - } as InspectorListItemDoctor, + type: 'offline-status', + } as unknown as InspectorListOfflineStatusChange, + { + type: 'comment', + } as unknown as InspectorListItemComment, ], - tab: SessionRecordingPlayerTab.ALL, - miniFiltersByKey: { 'all-everything': { enabled: true } as unknown as SharedListMiniFilter }, + miniFiltersByKey: { 'events-posthog': { enabled: false } as unknown as SharedListMiniFilter }, showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, + allowMatchingEventsFilter: false, + trackedWindow: null, }) - expect(filteredItems.map((item) => item.type)).toEqual(['browser-visibility']) - }) + ).toHaveLength(0) }) - describe('the events tab', () => { - it('filters by window id', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: SessionRecordingPlayerTab.EVENTS, - windowId: 'this window', - data: { event: '$exception' } as unknown as PerformanceEvent, - } as unknown as InspectorListItemEvent, - { - type: SessionRecordingPlayerTab.EVENTS, - windowId: 'a different window', - data: { event: '$exception' } as unknown as PerformanceEvent, - } as unknown as InspectorListItemEvent, - ], - tab: SessionRecordingPlayerTab.EVENTS, - miniFiltersByKey: { 'events-all': { enabled: true } as unknown as SharedListMiniFilter }, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: 'a different window', - }) - ).toHaveLength(1) - }) - - it('excludes browser visibility on console filter', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: 'browser-visibility', - } as InspectorListBrowserVisibility, - ], - tab: SessionRecordingPlayerTab.EVENTS, - miniFiltersByKey: { 'all-everything': { enabled: false } as unknown as SharedListMiniFilter }, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(0) - }) - - it('excludes browser visibility when show only matching', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: 'browser-visibility', - } as InspectorListBrowserVisibility, - ], - tab: SessionRecordingPlayerTab.EVENTS, - miniFiltersByKey: { 'all-everything': { enabled: true } as unknown as SharedListMiniFilter }, - showOnlyMatching: true, - showMatchingEventsFilter: true, - windowIdFilter: null, - }) - ).toHaveLength(0) - }) + it('shows context events when other events', () => { + expect( + filterInspectorListItems({ + allItems: [ + { + type: 'browser-visibility', + } as InspectorListBrowserVisibility, + { + type: 'offline-status', + } as unknown as InspectorListOfflineStatusChange, + { + type: 'comment', + } as unknown as InspectorListItemComment, + { + data: { event: '$pageview' }, + type: 'events', + } as InspectorListItemEvent, + ], + miniFiltersByKey: { 'events-pageview': { enabled: true } as unknown as SharedListMiniFilter }, + showOnlyMatching: false, + allowMatchingEventsFilter: false, + trackedWindow: null, + }).map((item) => item.type) + ).toEqual(['browser-visibility', 'offline-status', 'comment', 'events']) }) - describe('the doctor tab', () => { - it('ignores events that are not exceptions', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: SessionRecordingPlayerTab.EVENTS, - data: { event: 'an event' } as unknown as PerformanceEvent, - } as unknown as InspectorListItemEvent, - ], - tab: SessionRecordingPlayerTab.DOCTOR, - miniFiltersByKey: {}, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(0) + it.each([ + [true, 1], + [false, 0], + ])('hides/shows doctor items when %s', (enabled, expectedLength) => { + const filteredItems = filterInspectorListItems({ + allItems: [ + { + type: 'doctor', + } as InspectorListItemDoctor, + ], + miniFiltersByKey: { doctor: { enabled } as unknown as SharedListMiniFilter }, + showOnlyMatching: false, + allowMatchingEventsFilter: false, + trackedWindow: null, }) + expect(filteredItems).toHaveLength(expectedLength) + }) - it('includes events that are exceptions', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: SessionRecordingPlayerTab.EVENTS, - data: { event: '$exception' } as unknown as PerformanceEvent, - } as unknown as InspectorListItemEvent, - ], - tab: SessionRecordingPlayerTab.DOCTOR, - miniFiltersByKey: {}, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(1) - }) + it('filters by window id', () => { + expect( + filterInspectorListItems({ + allItems: [ + { + type: FilterableInspectorListItemTypes.EVENTS, + windowId: 'this window', + data: { event: '$exception' } as unknown as PerformanceEvent, + } as unknown as InspectorListItemEvent, + { + type: FilterableInspectorListItemTypes.EVENTS, + windowId: 'a different window', + data: { event: '$exception' } as unknown as PerformanceEvent, + } as unknown as InspectorListItemEvent, + ], + miniFiltersByKey: { 'events-exceptions': { enabled: true } as unknown as SharedListMiniFilter }, + showOnlyMatching: false, + allowMatchingEventsFilter: false, + trackedWindow: 'a different window', + }) + ).toHaveLength(1) + }) - it('includes browser offline status', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: 'offline-status', - } as unknown as InspectorListOfflineStatusChange, - ], - tab: SessionRecordingPlayerTab.DOCTOR, - miniFiltersByKey: {}, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(1) - }) + it('empty mini filters hides everything', () => { + expect( + filterInspectorListItems({ + allItems: [ + { + type: FilterableInspectorListItemTypes.EVENTS, + data: { event: 'an event' } as unknown as PerformanceEvent, + } as unknown as InspectorListItemEvent, + ], + miniFiltersByKey: {}, + showOnlyMatching: false, + allowMatchingEventsFilter: false, + trackedWindow: null, + }) + ).toHaveLength(0) + }) - it('includes browser visibility status', () => { - expect( - filterInspectorListItems({ - allItems: [ - { - type: 'browser-visibility', - } as InspectorListBrowserVisibility, - ], - tab: SessionRecordingPlayerTab.DOCTOR, - miniFiltersByKey: {}, - showOnlyMatching: false, - showMatchingEventsFilter: false, - windowIdFilter: null, - }) - ).toHaveLength(1) - }) + it.each([ + [true, 1], + [false, 0], + ])('hides/shows exceptions when %s', (enabled, expectedLength) => { + expect( + filterInspectorListItems({ + allItems: [ + { + type: FilterableInspectorListItemTypes.EVENTS, + data: { event: '$exception' } as unknown as PerformanceEvent, + } as unknown as InspectorListItemEvent, + ], + miniFiltersByKey: { 'events-exceptions': { enabled } as unknown as SharedListMiniFilter }, + showOnlyMatching: false, + allowMatchingEventsFilter: false, + trackedWindow: null, + }) + ).toHaveLength(expectedLength) }) }) diff --git a/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.ts b/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.ts index 071351202798f..e7a0859f9c000 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/inspectorListFiltering.ts @@ -1,17 +1,14 @@ import { InspectorListItemPerformance } from 'scenes/session-recordings/apm/performanceEventDataLogic' -import { SharedListMiniFilter } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' +import { MiniFilterKey, SharedListMiniFilter } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' import { IMAGE_WEB_EXTENSIONS, - InspectorListBrowserVisibility, InspectorListItem, - InspectorListItemComment, InspectorListItemConsole, InspectorListItemDoctor, InspectorListItemEvent, - InspectorListOfflineStatusChange, } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' -import { SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes } from '~/types' const PostHogMobileEvents = [ 'Deep Link Opened', @@ -31,27 +28,15 @@ function isPostHogEvent(item: InspectorListItem): boolean { } function isNetworkEvent(item: InspectorListItem): item is InspectorListItemPerformance { - return item.type === SessionRecordingPlayerTab.NETWORK -} - -function isOfflineStatusChange(item: InspectorListItem): item is InspectorListOfflineStatusChange { - return item.type === 'offline-status' -} - -function isBrowserVisibilityEvent(item: InspectorListItem): item is InspectorListBrowserVisibility { - return item.type === 'browser-visibility' + return item.type === FilterableInspectorListItemTypes.NETWORK } function isNavigationEvent(item: InspectorListItem): boolean { return isNetworkEvent(item) && ['navigation'].includes(item.data.entry_type || '') } -function isNetworkError(item: InspectorListItem): boolean { - return isNetworkEvent(item) && (item.data.response_status || -1) >= 400 -} - function isEvent(item: InspectorListItem): item is InspectorListItemEvent { - return item.type === SessionRecordingPlayerTab.EVENTS + return item.type === FilterableInspectorListItemTypes.EVENTS } function isPageviewOrScreen(item: InspectorListItem): boolean { @@ -63,7 +48,7 @@ function isAutocapture(item: InspectorListItem): boolean { } function isConsoleEvent(item: InspectorListItem): item is InspectorListItemConsole { - return item.type === SessionRecordingPlayerTab.CONSOLE + return item.type === FilterableInspectorListItemTypes.CONSOLE } function isConsoleError(item: InspectorListItem): boolean { @@ -82,107 +67,120 @@ function isDoctorEvent(item: InspectorListItem): item is InspectorListItemDoctor return item.type === 'doctor' } -function isComment(item: InspectorListItem): item is InspectorListItemComment { - return item.type === 'comment' +function isContextItem(item: InspectorListItem): boolean { + return ['browser-visibility', 'offline-status', 'comment', 'inspector-summary', 'inactivity'].includes(item.type) +} + +const eventsMatch = ( + item: InspectorListItemEvent, + miniFiltersByKey: { [p: MiniFilterKey]: SharedListMiniFilter } +): SharedListMiniFilter | null => { + if (isException(item) || isErrorEvent(item)) { + return miniFiltersByKey['events-exceptions'] + } else if (isAutocapture(item)) { + return miniFiltersByKey['events-autocapture'] + } else if (isPageviewOrScreen(item)) { + return miniFiltersByKey['events-pageview'] + } else if (isPostHogEvent(item)) { + return miniFiltersByKey['events-posthog'] + } else if (!isPostHogEvent(item)) { + return miniFiltersByKey['events-custom'] + } + return null +} + +const consoleMatch = ( + item: InspectorListItemConsole, + miniFiltersByKey: { [p: MiniFilterKey]: SharedListMiniFilter } +): SharedListMiniFilter | null => { + if (['log', 'info'].includes(item.data.level)) { + return miniFiltersByKey['console-info'] + } else if (item.data.level === 'warn') { + return miniFiltersByKey['console-warn'] + } else if (isConsoleError(item)) { + return miniFiltersByKey['console-error'] + } + return null } -const inspectorTabFilters: Record< - SessionRecordingPlayerTab, - ( - item: InspectorListItem, - miniFiltersByKey: { - [key: string]: SharedListMiniFilter - } - ) => boolean -> = { - [SessionRecordingPlayerTab.ALL]: (item, miniFiltersByKey) => { - // even in everything mode we don't show doctor events - const isAllEverything = miniFiltersByKey['all-everything']?.enabled === true && !isDoctorEvent(item) - const isAllAutomatic = - !!miniFiltersByKey['all-automatic']?.enabled && - (isOfflineStatusChange(item) || isBrowserVisibilityEvent(item) || isEvent(item) || isComment(item)) - const isAllErrors = - !!miniFiltersByKey['all-errors']?.enabled && - (isNetworkError(item) || isConsoleError(item) || isException(item) || isErrorEvent(item)) - return isAllEverything || isAllAutomatic || isAllErrors - }, - [SessionRecordingPlayerTab.EVENTS]: (item, miniFiltersByKey) => { - if (item.type !== SessionRecordingPlayerTab.EVENTS) { - return false - } - return ( - !!miniFiltersByKey['events-all']?.enabled || - (!!miniFiltersByKey['events-posthog']?.enabled && isPostHogEvent(item)) || - (!!miniFiltersByKey['events-custom']?.enabled && !isPostHogEvent(item)) || - (!!miniFiltersByKey['events-pageview']?.enabled && isPageviewOrScreen(item)) || - (!!miniFiltersByKey['events-autocapture']?.enabled && isAutocapture(item)) || - (!!miniFiltersByKey['events-exceptions']?.enabled && isException(item)) - ) - }, - [SessionRecordingPlayerTab.CONSOLE]: (item, miniFiltersByKey) => { - if (item.type !== SessionRecordingPlayerTab.CONSOLE) { - return false - } - return ( - !!miniFiltersByKey['console-all']?.enabled || - (!!miniFiltersByKey['console-info']?.enabled && ['log', 'info'].includes(item.data.level)) || - (!!miniFiltersByKey['console-warn']?.enabled && item.data.level === 'warn') || - (!!miniFiltersByKey['console-error']?.enabled && isConsoleError(item)) - ) - }, - [SessionRecordingPlayerTab.NETWORK]: (item, miniFiltersByKey) => { - if (item.type !== SessionRecordingPlayerTab.NETWORK) { - return false - } - return ( - !!miniFiltersByKey['performance-all']?.enabled === true || - (!!miniFiltersByKey['performance-document']?.enabled && isNavigationEvent(item)) || - (!!miniFiltersByKey['performance-fetch']?.enabled && - item.data.entry_type === 'resource' && - ['fetch', 'xmlhttprequest'].includes(item.data.initiator_type || '')) || - (!!miniFiltersByKey['performance-assets-js']?.enabled && - item.data.entry_type === 'resource' && - (item.data.initiator_type === 'script' || - (['link', 'other'].includes(item.data.initiator_type || '') && item.data.name?.includes('.js')))) || - (!!miniFiltersByKey['performance-assets-css']?.enabled && - item.data.entry_type === 'resource' && - (item.data.initiator_type === 'css' || - (['link', 'other'].includes(item.data.initiator_type || '') && - item.data.name?.includes('.css')))) || - (!!miniFiltersByKey['performance-assets-img']?.enabled && - item.data.entry_type === 'resource' && - (item.data.initiator_type === 'img' || - (['link', 'other'].includes(item.data.initiator_type || '') && - !!IMAGE_WEB_EXTENSIONS.some((ext) => item.data.name?.includes(`.${ext}`))))) || - (!!miniFiltersByKey['performance-other']?.enabled && - item.data.entry_type === 'resource' && - ['other'].includes(item.data.initiator_type || '') && - ![...IMAGE_WEB_EXTENSIONS, 'css', 'js'].some((ext) => item.data.name?.includes(`.${ext}`))) - ) - }, - [SessionRecordingPlayerTab.DOCTOR]: (item) => { - return isOfflineStatusChange(item) || isBrowserVisibilityEvent(item) || isException(item) || isDoctorEvent(item) - }, +function networkMatch( + item: InspectorListItemPerformance, + miniFiltersByKey: { + [p: MiniFilterKey]: SharedListMiniFilter + } +): SharedListMiniFilter | null { + if (isNavigationEvent(item)) { + return miniFiltersByKey['performance-document'] + } else if ( + item.data.entry_type === 'resource' && + ['fetch', 'xmlhttprequest'].includes(item.data.initiator_type || '') + ) { + return miniFiltersByKey['performance-fetch'] + } else if ( + item.data.entry_type === 'resource' && + (item.data.initiator_type === 'script' || + (['link', 'other'].includes(item.data.initiator_type || '') && item.data.name?.includes('.js'))) + ) { + return miniFiltersByKey['performance-assets-js'] + } else if ( + item.data.entry_type === 'resource' && + (item.data.initiator_type === 'css' || + (['link', 'other'].includes(item.data.initiator_type || '') && item.data.name?.includes('.css'))) + ) { + return miniFiltersByKey['performance-assets-css'] + } else if ( + item.data.entry_type === 'resource' && + (item.data.initiator_type === 'img' || + (['link', 'other'].includes(item.data.initiator_type || '') && + !!IMAGE_WEB_EXTENSIONS.some((ext) => item.data.name?.includes(`.${ext}`)))) + ) { + return miniFiltersByKey['performance-assets-img'] + } else if ( + item.data.entry_type === 'resource' && + ['other'].includes(item.data.initiator_type || '') && + ![...IMAGE_WEB_EXTENSIONS, 'css', 'js'].some((ext) => item.data.name?.includes(`.${ext}`)) + ) { + return miniFiltersByKey['performance-other'] + } + return null +} + +export function itemToMiniFilter( + item: InspectorListItem, + miniFiltersByKey: { [p: MiniFilterKey]: SharedListMiniFilter } +): SharedListMiniFilter | null { + switch (item.type) { + case FilterableInspectorListItemTypes.EVENTS: + return eventsMatch(item, miniFiltersByKey) + case FilterableInspectorListItemTypes.CONSOLE: + return consoleMatch(item, miniFiltersByKey) + case FilterableInspectorListItemTypes.NETWORK: + return networkMatch(item, miniFiltersByKey) + case FilterableInspectorListItemTypes.DOCTOR: + if (isDoctorEvent(item)) { + return miniFiltersByKey['doctor'] + } + break + } + return null } export function filterInspectorListItems({ allItems, - tab, miniFiltersByKey, - showMatchingEventsFilter, + allowMatchingEventsFilter, showOnlyMatching, - windowIdFilter, + trackedWindow, }: { allItems: InspectorListItem[] - tab: SessionRecordingPlayerTab miniFiltersByKey: | { - [key: string]: SharedListMiniFilter + [key: MiniFilterKey]: SharedListMiniFilter } | undefined - showMatchingEventsFilter: boolean + allowMatchingEventsFilter: boolean showOnlyMatching: boolean - windowIdFilter: string | null + trackedWindow: string | null }): InspectorListItem[] { const items: InspectorListItem[] = [] @@ -200,15 +198,18 @@ export function filterInspectorListItems({ continue } - include = inspectorTabFilters[tab](item, miniFiltersByKey) + const itemFilter = itemToMiniFilter(item, miniFiltersByKey) + include = isContextItem(item) || !!itemFilter?.enabled + + // what about isOfflineStatusChange(item) || isBrowserVisibilityEvent(item) || isComment(item) - if (showMatchingEventsFilter && showOnlyMatching) { + if (allowMatchingEventsFilter && showOnlyMatching) { // Special case - overrides the others include = include && item.highlightColor === 'primary' } const itemWindowId = item.windowId // how do we use sometimes properties $window_id... maybe we just shouldn't need to :shrug: - const excludedByWindowFilter = !!windowIdFilter && !!itemWindowId && itemWindowId !== windowIdFilter + const excludedByWindowFilter = !!trackedWindow && !!itemWindowId && itemWindowId !== trackedWindow if (!include || excludedByWindowFilter) { continue @@ -217,5 +218,5 @@ export function filterInspectorListItems({ items.push(item) } - return items + return items.every((i) => isContextItem(i)) ? [] : items } diff --git a/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.test.ts b/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.test.ts index 1418f6a13a7f6..2f1b83ebca39d 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.test.ts @@ -3,7 +3,6 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' import { initKeaTests } from '~/test/init' -import { SessionRecordingPlayerTab } from '~/types' describe('miniFiltersLogic', () => { let logic: ReturnType @@ -19,9 +18,17 @@ describe('miniFiltersLogic', () => { describe('initialState', () => { it('sets default values', () => { expectLogic(logic).toMatchValues({ - tab: SessionRecordingPlayerTab.ALL, showOnlyMatching: false, - selectedMiniFilters: ['all-automatic', 'console-all', 'events-all', 'performance-all'], + selectedMiniFilters: [ + 'events-posthog', + 'events-custom', + 'events-pageview', + 'events-autocapture', + 'events-exceptions', + 'console-info', + 'console-warn', + 'console-error', + ], }) }) }) @@ -48,66 +55,20 @@ describe('miniFiltersLogic', () => { localStorage.clear() }) - it('should start with the first entry selected', () => { - expect(logic.values.selectedMiniFilters).toEqual([ - 'all-automatic', - 'console-all', - 'events-all', - 'performance-all', - ]) - }) - - it('should remove other selected filters if alone', () => { - logic.actions.setMiniFilter('all-errors', true) - - expect(logic.values.selectedMiniFilters.sort()).toEqual([ - 'all-errors', - 'console-all', - 'events-all', - 'performance-all', - ]) - }) - - it('should allow multiple filters if not alone', () => { - logic.actions.setMiniFilter('console-warn', true) - logic.actions.setMiniFilter('console-info', true) - - expect(logic.values.selectedMiniFilters.sort()).toEqual([ - 'all-automatic', - 'console-info', - 'console-warn', - 'events-all', - 'performance-all', - ]) - }) - - it('should reset to first in tab if empty', () => { - expect(logic.values.selectedMiniFilters.sort()).toEqual([ - 'all-automatic', - 'console-all', - 'events-all', - 'performance-all', - ]) - logic.actions.setMiniFilter('console-warn', true) - logic.actions.setMiniFilter('console-info', true) - - expect(logic.values.selectedMiniFilters.sort()).toEqual([ - 'all-automatic', - 'console-info', - 'console-warn', - 'events-all', - 'performance-all', - ]) - - logic.actions.setMiniFilter('console-warn', false) - logic.actions.setMiniFilter('console-info', false) - - expect(logic.values.selectedMiniFilters.sort()).toEqual([ - 'all-automatic', - 'console-all', - 'events-all', - 'performance-all', - ]) + it('can unselect', async () => { + await expectLogic(logic, () => { + logic.actions.setMiniFilter('events-posthog', false) + }).toMatchValues({ + selectedMiniFilters: [ + 'events-custom', + 'events-pageview', + 'events-autocapture', + 'events-exceptions', + 'console-info', + 'console-warn', + 'console-error', + ], + }) }) }) }) diff --git a/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.ts index e6f6d92d77752..e7388ba569c83 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/miniFiltersLogic.ts @@ -2,177 +2,115 @@ import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { teamLogic } from 'scenes/teamLogic' -import { SessionRecordingPlayerTab } from '~/types' +import { FilterableInspectorListItemTypes } from '~/types' import type { miniFiltersLogicType } from './miniFiltersLogicType' export type SharedListMiniFilter = { - tab: SessionRecordingPlayerTab + type: FilterableInspectorListItemTypes key: string name: string - // If alone, then enabling it will disable all the others - alone?: boolean tooltip?: string enabled?: boolean } const MiniFilters: SharedListMiniFilter[] = [ { - tab: SessionRecordingPlayerTab.ALL, - key: 'all-automatic', - name: 'Auto', - alone: true, - tooltip: 'Curated list of key PostHog events, custom events, error logs etc.', - }, - { - tab: SessionRecordingPlayerTab.ALL, - key: 'all-errors', - name: 'Errors', - alone: true, - tooltip: 'Events containing "error" or "exception" in their name and console errors', - }, - { - tab: SessionRecordingPlayerTab.ALL, - key: 'all-everything', - name: 'Everything', - alone: true, - tooltip: 'Everything that happened in this session', - }, - { - tab: SessionRecordingPlayerTab.EVENTS, - key: 'events-all', - name: 'All', - alone: true, - tooltip: 'All events tracked during this session', - }, - { - tab: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, key: 'events-posthog', name: 'PostHog', - tooltip: 'Standard PostHog events like Pageviews, Autocapture etc.', + tooltip: 'Standard PostHog events except Pageviews, Autocapture, and Exceptions.', }, { - tab: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, key: 'events-custom', name: 'Custom', tooltip: 'Custom events tracked by your app', }, { - tab: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, key: 'events-pageview', name: 'Pageview / Screen', tooltip: 'Pageview (or Screen for mobile) events', }, { - tab: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, key: 'events-autocapture', name: 'Autocapture', tooltip: 'Autocapture events such as clicks and inputs', }, { - tab: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, key: 'events-exceptions', name: 'Exceptions', tooltip: 'Exception events from PostHog or its Sentry integration', }, { - tab: SessionRecordingPlayerTab.CONSOLE, - key: 'console-all', - name: 'All', - alone: true, - }, - { - tab: SessionRecordingPlayerTab.CONSOLE, + type: FilterableInspectorListItemTypes.CONSOLE, key: 'console-info', name: 'Info', }, { - tab: SessionRecordingPlayerTab.CONSOLE, + type: FilterableInspectorListItemTypes.CONSOLE, key: 'console-warn', name: 'Warn', }, { - tab: SessionRecordingPlayerTab.CONSOLE, + type: FilterableInspectorListItemTypes.CONSOLE, key: 'console-error', name: 'Error', }, { - tab: SessionRecordingPlayerTab.NETWORK, - key: 'performance-all', - name: 'All', - alone: true, - tooltip: 'All network performance information collected during the session', - }, - { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-fetch', name: 'Fetch/XHR', tooltip: 'Requests during the session to external resources like APIs via XHR or Fetch', }, { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-document', name: 'Doc', tooltip: 'Page load information collected on a fresh browser page load, refresh, or page paint.', }, { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-assets-js', name: 'JS', tooltip: 'Scripts loaded during the session.', }, { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-assets-css', name: 'CSS', tooltip: 'CSS loaded during the session.', }, { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-assets-img', name: 'Img', tooltip: 'Images loaded during the session.', }, { - tab: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, key: 'performance-other', name: 'Other', tooltip: 'Any other network requests that do not fall into the other categories', }, - - // NOTE: The below filters use the `response_status` property which is currently experiemental - // and as such doesn't show for many browsers: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus - // We should only add these in if the recording in question has those values (otherwiseit is a confusing experience for the user) - - // { - // tab: SessionRecordingPlayerTab.PERFORMANCE, - // key: 'performance-2xx', - // name: '2xx', - // tooltip: - // 'Requests that returned a HTTP status code of 2xx. The request was successfully received, understood, and accepted.', - // }, - // { - // tab: SessionRecordingPlayerTab.PERFORMANCE, - // key: 'performance-4xx', - // name: '4xx', - // tooltip: - // 'Requests that returned a HTTP status code of 4xx. The request contains bad syntax or cannot be fulfilled.', - // }, - // { - // tab: SessionRecordingPlayerTab.PERFORMANCE, - // key: 'performance-5xx', - // name: '5xx', - // tooltip: - // 'Requests that returned a HTTP status code of 5xx. The server failed to fulfil an apparently valid request.', - // }, + { + type: FilterableInspectorListItemTypes.DOCTOR, + key: 'doctor', + name: 'Doctor', + tooltip: + 'Doctor events are special events that are automatically detected by PostHog to help diagnose issues in replay.', + }, ] +export type MiniFilterKey = (typeof MiniFilters)[number]['key'] export const miniFiltersLogic = kea([ path(['scenes', 'session-recordings', 'player', 'miniFiltersLogic']), actions({ setShowOnlyMatching: (showOnlyMatching: boolean) => ({ showOnlyMatching }), - setTab: (tab: SessionRecordingPlayerTab) => ({ tab }), - setMiniFilter: (key: string, enabled: boolean) => ({ key, enabled }), + setMiniFilter: (key: MiniFilterKey, enabled: boolean) => ({ key, enabled }), setSearchQuery: (search: string) => ({ search }), }), connect({ @@ -187,55 +125,28 @@ export const miniFiltersLogic = kea([ }, ], - tab: [ - SessionRecordingPlayerTab.ALL as SessionRecordingPlayerTab, - { persist: true }, - { - setTab: (_, { tab }) => tab, - }, - ], - selectedMiniFilters: [ - ['all-automatic', 'console-all', 'events-all', 'performance-all'] as string[], + [ + 'events-posthog', + 'events-custom', + 'events-pageview', + 'events-autocapture', + 'events-exceptions', + 'console-info', + 'console-warn', + 'console-error', + ] as MiniFilterKey[], { persist: true }, { setMiniFilter: (state, { key, enabled }) => { - const selectedFilter = MiniFilters.find((x) => x.key === key) - - if (!selectedFilter) { - return state - } - const filtersInTab = MiniFilters.filter((x) => x.tab === selectedFilter.tab) - - const newFilters = state.filter((existingSelected) => { - const filterInTab = filtersInTab.find((x) => x.key === existingSelected) - if (!filterInTab) { - return true - } - - if (enabled) { - if (selectedFilter.alone) { - return false - } - return filterInTab.alone ? false : true - } - - if (existingSelected !== key) { - return true - } - return false - }) - + const stateWithoutKey = state.filter((x) => x !== key) if (enabled) { - newFilters.push(key) - } else { - // Ensure the first one is checked if no others - if (filtersInTab.every((x) => !newFilters.includes(x.key))) { - newFilters.push(filtersInTab[0].key) - } + // ensure it's in the array + // remove it if it's there and then add it back + return stateWithoutKey.concat(key) } - - return newFilters + // ensure it's not in the array + return stateWithoutKey }, }, ], @@ -249,11 +160,11 @@ export const miniFiltersLogic = kea([ })), selectors({ - miniFiltersForTab: [ + miniFiltersForType: [ (s) => [s.selectedMiniFilters], - (selectedMiniFilters): ((tab: SessionRecordingPlayerTab) => SharedListMiniFilter[]) => { - return (tab: SessionRecordingPlayerTab) => { - return MiniFilters.filter((filter) => filter.tab === tab).map((x) => ({ + (selectedMiniFilters): ((tab: FilterableInspectorListItemTypes) => SharedListMiniFilter[]) => { + return (tab: FilterableInspectorListItemTypes) => { + return MiniFilters.filter((filter) => filter.type === tab).map((x) => ({ ...x, enabled: selectedMiniFilters.includes(x.key), })) @@ -262,9 +173,12 @@ export const miniFiltersLogic = kea([ ], miniFilters: [ - (s) => [s.tab, s.miniFiltersForTab], - (tab, miniFiltersForTab): SharedListMiniFilter[] => { - return miniFiltersForTab(tab) + (s) => [s.selectedMiniFilters], + (selectedMiniFilters): SharedListMiniFilter[] => { + return MiniFilters.map((x) => ({ + ...x, + enabled: selectedMiniFilters.includes(x.key), + })) }, ], @@ -278,11 +192,13 @@ export const miniFiltersLogic = kea([ }, ], - miniFiltersForTabByKey: [ - (s) => [s.miniFiltersForTab], - (miniFiltersForTab): ((tab: SessionRecordingPlayerTab) => { [key: string]: SharedListMiniFilter }) => { + miniFiltersForTypeByKey: [ + (s) => [s.miniFiltersForType], + ( + miniFiltersForType + ): ((tab: FilterableInspectorListItemTypes) => { [key: string]: SharedListMiniFilter }) => { return (tab) => { - return miniFiltersForTab(tab).reduce((acc, filter) => { + return miniFiltersForType(tab).reduce((acc, filter) => { acc[filter.key] = filter return acc }, {}) @@ -290,13 +206,10 @@ export const miniFiltersLogic = kea([ }, ], }), - listeners(({ values }) => ({ - setTab: ({ tab }) => { - eventUsageLogic.actions.reportRecordingInspectorTabViewed(tab) - }, + listeners(() => ({ setMiniFilter: ({ key, enabled }) => { if (enabled) { - eventUsageLogic.actions.reportRecordingInspectorMiniFilterViewed(values.tab, key) + eventUsageLogic.actions.reportRecordingInspectorMiniFilterViewed(key, enabled) } }, })), diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts index dafd05c304ff3..e2ac2aa0e96e0 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.test.ts @@ -65,26 +65,27 @@ describe('playerInspectorLogic', () => { }) }) - describe('setWindowIdFilter', () => { - it('happy case', async () => { - await expectLogic(logic).toMatchValues({ - windowIdFilter: null, - }) + describe('setTrackedWindow', () => { + it('starts with no tracked window', async () => { await expectLogic(logic, () => { - logic.actions.setWindowIdFilter('nightly') + logic.actions.setTrackedWindow(null as unknown as string) }) - .toDispatchActions(['setWindowIdFilter']) + .toDispatchActions(['setTrackedWindow']) .toMatchValues({ - windowIdFilter: 'nightly', + trackedWindow: null, }) }) - it('default all', async () => { + + it('can set tracked window', async () => { + await expectLogic(logic).toMatchValues({ + trackedWindow: null, + }) await expectLogic(logic, () => { - logic.actions.setWindowIdFilter(null as unknown as string) + logic.actions.setTrackedWindow('nightly') }) - .toDispatchActions(['setWindowIdFilter']) + .toDispatchActions(['setTrackedWindow']) .toMatchValues({ - windowIdFilter: null, + trackedWindow: 'nightly', }) }) }) diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts index 1b94f2db41f08..f3d380dbc14f7 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts @@ -12,20 +12,23 @@ import { InspectorListItemPerformance, performanceEventDataLogic, } from 'scenes/session-recordings/apm/performanceEventDataLogic' -import { filterInspectorListItems } from 'scenes/session-recordings/player/inspector/inspectorListFiltering' -import { miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' +import { + filterInspectorListItems, + itemToMiniFilter, +} from 'scenes/session-recordings/player/inspector/inspectorListFiltering' +import { MiniFilterKey, miniFiltersLogic } from 'scenes/session-recordings/player/inspector/miniFiltersLogic' import { convertUniversalFiltersToRecordingsQuery, MatchingEventsMatchType, } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { + FilterableInspectorListItemTypes, MatchedRecordingEvent, PerformanceEvent, RecordingConsoleLogV2, RecordingEventType, RRWebRecordingConsoleLogPayload, - SessionRecordingPlayerTab, } from '~/types' import { sessionRecordingDataLogic } from '../sessionRecordingDataLogic' @@ -54,6 +57,14 @@ export const IMAGE_WEB_EXTENSIONS = [ // Helping kea-typegen navigate the exported default class for Fuse export interface Fuse extends FuseClass {} +export type RecordingComment = { + id: string + notebookShortId: string + notebookTitle: string + comment: string + timeInRecording: number +} + export type InspectorListItemBase = { timestamp: Dayjs timeInRecording: number @@ -63,17 +74,16 @@ export type InspectorListItemBase = { windowNumber?: number | '?' | undefined } +export type InspectorListItemType = InspectorListItem['type'] + export type InspectorListItemEvent = InspectorListItemBase & { - type: SessionRecordingPlayerTab.EVENTS + type: FilterableInspectorListItemTypes.EVENTS data: RecordingEventType } -export type RecordingComment = { - id: string - notebookShortId: string - notebookTitle: string - comment: string - timeInRecording: number +export type InspectorListItemInactivity = InspectorListItemBase & { + type: 'inactivity' + durationMs: number } export type InspectorListItemComment = InspectorListItemBase & { @@ -82,7 +92,7 @@ export type InspectorListItemComment = InspectorListItemBase & { } export type InspectorListItemConsole = InspectorListItemBase & { - type: SessionRecordingPlayerTab.CONSOLE + type: FilterableInspectorListItemTypes.CONSOLE data: RecordingConsoleLogV2 } @@ -97,12 +107,19 @@ export type InspectorListBrowserVisibility = InspectorListItemBase & { } export type InspectorListItemDoctor = InspectorListItemBase & { - type: SessionRecordingPlayerTab.DOCTOR + type: FilterableInspectorListItemTypes.DOCTOR tag: string data?: Record window_id?: string } +export type InspectorListItemSummary = InspectorListItemBase & { + type: 'inspector-summary' + clickCount: number | null + keypressCount: number | null + errorCount: number | null +} + export type InspectorListItem = | InspectorListItemEvent | InspectorListItemConsole @@ -111,6 +128,8 @@ export type InspectorListItem = | InspectorListItemDoctor | InspectorListBrowserVisibility | InspectorListItemComment + | InspectorListItemSummary + | InspectorListItemInactivity export interface PlayerInspectorLogicProps extends SessionRecordingPlayerLogicProps { matchingEventsMatchType?: MatchingEventsMatchType @@ -141,7 +160,12 @@ function snapshotDescription(snapshot: eventWithTime): string { } function timeRelativeToStart( - thingWithTime: eventWithTime | PerformanceEvent | RecordingConsoleLogV2 | RecordingEventType, + thingWithTime: + | eventWithTime + | PerformanceEvent + | RecordingConsoleLogV2 + | RecordingEventType + | { timestamp: number }, start: Dayjs | null ): { timeInRecording: number @@ -193,15 +217,15 @@ export const playerInspectorLogic = kea([ connect((props: PlayerInspectorLogicProps) => ({ actions: [ miniFiltersLogic, - ['setTab', 'setMiniFilter', 'setSearchQuery'], + ['setMiniFilter', 'setSearchQuery'], eventUsageLogic, ['reportRecordingInspectorItemExpanded'], sessionRecordingDataLogic(props), - ['loadFullEventData'], + ['loadFullEventData', 'setTrackedWindow'], ], values: [ miniFiltersLogic, - ['showOnlyMatching', 'tab', 'miniFiltersByKey', 'searchQuery', 'miniFiltersForTabByKey'], + ['showOnlyMatching', 'miniFiltersByKey', 'searchQuery', 'miniFiltersForTypeByKey', 'miniFilters'], sessionRecordingDataLogic(props), [ 'sessionPlayerData', @@ -215,43 +239,37 @@ export const playerInspectorLogic = kea([ 'durationMs', 'sessionComments', 'windowIdForTimestamp', + 'sessionPlayerMetaData', + 'segments', ], sessionRecordingPlayerLogic(props), ['currentPlayerTime'], performanceEventDataLogic({ key: props.playerKey, sessionRecordingId: props.sessionRecordingId }), ['allPerformanceEvents'], + sessionRecordingDataLogic(props), + ['trackedWindow'], ], })), actions(() => ({ - setWindowIdFilter: (windowId: string | null) => ({ windowId }), setItemExpanded: (index: number, expanded: boolean) => ({ index, expanded }), setSyncScrollPaused: (paused: boolean) => ({ paused }), })), reducers(() => ({ - windowIdFilter: [ - null as string | null, - { - setWindowIdFilter: (_, { windowId }) => windowId || null, - }, - ], expandedItems: [ [] as number[], { setItemExpanded: (items, { index, expanded }) => { return expanded ? [...items, index] : items.filter((item) => item !== index) }, - - setTab: () => [], setMiniFilter: () => [], setSearchQuery: () => [], - setWindowIdFilter: () => [], + setTrackedWindow: () => [], }, ], syncScrollPaused: [ false, { - setTab: () => false, setSyncScrollPaused: (_, { paused }) => paused, setItemExpanded: () => true, }, @@ -290,10 +308,13 @@ export const playerInspectorLogic = kea([ ], })), selectors(({ props }) => ({ - showMatchingEventsFilter: [ - (s) => [s.tab], - (tab): boolean => { - return tab === SessionRecordingPlayerTab.EVENTS && props.matchingEventsMatchType?.matchType !== 'none' + allowMatchingEventsFilter: [ + (s) => [s.miniFilters], + (miniFilters): boolean => { + return ( + miniFilters.some((mf) => mf.type === FilterableInspectorListItemTypes.EVENTS && mf.enabled) && + props.matchingEventsMatchType?.matchType !== 'none' + ) }, ], @@ -413,13 +434,12 @@ export const playerInspectorLogic = kea([ const { timestamp, timeInRecording } = timeRelativeToStart(snapshot, start) items.push({ - type: SessionRecordingPlayerTab.DOCTOR, + type: FilterableInspectorListItemTypes.DOCTOR, timestamp, timeInRecording, tag: niceify(tag), search: niceify(tag), window_id: windowId, - // TODO why both? windowId: windowId, windowNumber: windowNumberForID(windowId), data: getPayloadFor(customEvent, tag), @@ -429,13 +449,12 @@ export const playerInspectorLogic = kea([ const { timestamp, timeInRecording } = timeRelativeToStart(snapshot, start) items.push({ - type: SessionRecordingPlayerTab.DOCTOR, + type: FilterableInspectorListItemTypes.DOCTOR, timestamp, timeInRecording, tag: 'full snapshot event', search: 'full snapshot event', window_id: windowId, - // TODO why both? windowId: windowId, windowNumber: windowNumberForID(windowId), data: { snapshotSize: humanizeBytes(estimateSize(snapshot)) }, @@ -445,7 +464,7 @@ export const playerInspectorLogic = kea([ }) items.push({ - type: SessionRecordingPlayerTab.DOCTOR, + type: FilterableInspectorListItemTypes.DOCTOR, timestamp: start, timeInRecording: 0, tag: 'count of snapshot types by window', @@ -505,39 +524,50 @@ export const playerInspectorLogic = kea([ }, ], - allItems: [ + allContextItems: [ (s) => [ s.start, - s.allPerformanceEvents, - s.consoleLogs, - s.sessionEventsData, - s.matchingEventUUIDs, s.offlineStatusChanges, s.doctorEvents, s.browserVisibilityChanges, s.sessionComments, s.windowIdForTimestamp, s.windowNumberForID, + s.sessionPlayerMetaData, + s.segments, ], ( start, - performanceEvents, - consoleLogs, - eventsData, - matchingEventUUIDs, offlineStatusChanges, doctorEvents, browserVisibilityChanges, sessionComments, windowIdForTimestamp, - windowNumberForID - ): InspectorListItem[] => { - // NOTE: Possible perf improvement here would be to have a selector to parse the items - // and then do the filtering of what items are shown, elsewhere - // ALSO: We could move the individual filtering logic into the MiniFilters themselves - // WARNING: Be careful of dayjs functions - they can be slow due to the size of the loop. + windowNumberForID, + sessionPlayerMetaData, + segments + ) => { const items: InspectorListItem[] = [] + segments + .filter((segment) => segment.kind === 'gap') + .filter((segment) => segment.durationMs > 15000) + .map((segment) => { + const { timestamp, timeInRecording } = timeRelativeToStart( + { timestamp: segment.startTimestamp }, + start + ) + items.push({ + type: 'inactivity', + durationMs: segment.durationMs, + windowId: segment.windowId, + windowNumber: windowNumberForID(segment.windowId), + timestamp, + timeInRecording, + search: 'inactiv', + }) + }) + // no conversion needed for offlineStatusChanges, they're ready to roll for (const event of offlineStatusChanges || []) { items.push(event) @@ -553,11 +583,72 @@ export const playerInspectorLogic = kea([ items.push(event) } + for (const comment of sessionComments || []) { + const { timestamp, timeInRecording } = commentTimestamp(comment, start) + if (timestamp) { + items.push({ + highlightColor: 'primary', + type: 'comment', + timeInRecording: timeInRecording, + timestamp: timestamp, + search: comment.comment, + data: comment, + windowId: windowIdForTimestamp(timestamp.valueOf()), + windowNumber: windowNumberForID(windowIdForTimestamp(timestamp.valueOf())), + }) + } + } + + // now we've calculated everything else + // always start with a context row that has a little summary + if (start) { + items.push({ + type: 'inspector-summary', + timestamp: start, + timeInRecording: 0, + search: '', + clickCount: sessionPlayerMetaData?.click_count || null, + keypressCount: sessionPlayerMetaData?.keypress_count || null, + errorCount: 0, + }) + } + + // NOTE: Native JS sorting is relatively slow here - be careful changing this + items.sort((a, b) => (a.timestamp.valueOf() > b.timestamp.valueOf() ? 1 : -1)) + + return items + }, + ], + + allItems: [ + (s) => [ + s.start, + s.allPerformanceEvents, + s.consoleLogs, + s.sessionEventsData, + s.matchingEventUUIDs, + s.windowNumberForID, + s.allContextItems, + ], + ( + start, + performanceEvents, + consoleLogs, + eventsData, + matchingEventUUIDs, + windowNumberForID, + allContextItems + ): InspectorListItem[] => { + // NOTE: Possible perf improvement here would be to have a selector to parse the items + // and then do the filtering of what items are shown, elsewhere + // ALSO: We could move the individual filtering logic into the MiniFilters themselves + // WARNING: Be careful of dayjs functions - they can be slow due to the size of the loop. + const items: InspectorListItem[] = [] + // PERFORMANCE EVENTS const performanceEventsArr = performanceEvents || [] for (const event of performanceEventsArr) { - // TODO should we be defaulting to 200 here :shrug: - const responseStatus = event.response_status || 200 + const responseStatus = event.response_status || null if (event.entry_type === 'paint') { // We don't include paint events as they are covered in the navigation events @@ -566,12 +657,12 @@ export const playerInspectorLogic = kea([ const { timestamp, timeInRecording } = timeRelativeToStart(event, start) items.push({ - type: SessionRecordingPlayerTab.NETWORK, + type: FilterableInspectorListItemTypes.NETWORK, timestamp, timeInRecording, search: event.name || '', data: event, - highlightColor: responseStatus >= 400 ? 'danger' : undefined, + highlightColor: (responseStatus || 0) >= 400 ? 'danger' : undefined, windowId: event.window_id, windowNumber: windowNumberForID(event.window_id), }) @@ -581,7 +672,7 @@ export const playerInspectorLogic = kea([ for (const event of consoleLogs || []) { const { timestamp, timeInRecording } = timeRelativeToStart(event, start) items.push({ - type: SessionRecordingPlayerTab.CONSOLE, + type: FilterableInspectorListItemTypes.CONSOLE, timestamp, timeInRecording, search: event.content, @@ -593,9 +684,14 @@ export const playerInspectorLogic = kea([ }) } + let errorCount = 0 for (const event of eventsData || []) { let isMatchingEvent = false + if (event.event === '$exception') { + errorCount += 1 + } + if (matchingEventUUIDs?.length) { isMatchingEvent = !!matchingEventUUIDs.find((x) => x.uuid === String(event.id)) } else if (props.matchingEventsMatchType?.matchType === 'name') { @@ -610,7 +706,7 @@ export const playerInspectorLogic = kea([ const { timestamp, timeInRecording } = timeRelativeToStart(event, start) items.push({ - type: SessionRecordingPlayerTab.EVENTS, + type: FilterableInspectorListItemTypes.EVENTS, timestamp, timeInRecording, search: search, @@ -625,90 +721,92 @@ export const playerInspectorLogic = kea([ }) } - for (const comment of sessionComments || []) { - const { timestamp, timeInRecording } = commentTimestamp(comment, start) - if (timestamp) { - items.push({ - highlightColor: 'primary', - type: 'comment', - timeInRecording: timeInRecording, - timestamp: timestamp, - search: comment.comment, - data: comment, - windowId: windowIdForTimestamp(timestamp.valueOf()), - windowNumber: windowNumberForID(windowIdForTimestamp(timestamp.valueOf())), - }) - } + for (const event of allContextItems || []) { + items.push(event) } // NOTE: Native JS sorting is relatively slow here - be careful changing this items.sort((a, b) => (a.timestamp.valueOf() > b.timestamp.valueOf() ? 1 : -1)) + // ensure that item with type 'inspector-summary' is always at the top + const summary = items.find((item) => item.type === 'inspector-summary') + if (summary) { + ;(summary as InspectorListItemSummary).errorCount = errorCount + items.splice(items.indexOf(summary), 1) + items.unshift(summary) + } + if (items.length > 0) { + items[0].windowNumber = items[1]?.windowNumber + items[0].windowId = items[1]?.windowId + } + return items }, ], filteredItems: [ - (s) => [ - s.allItems, - s.tab, - s.miniFiltersByKey, - s.showOnlyMatching, - s.showMatchingEventsFilter, - s.windowIdFilter, - ], + (s) => [s.allItems, s.miniFiltersByKey, s.showOnlyMatching, s.allowMatchingEventsFilter, s.trackedWindow], ( allItems, - tab, miniFiltersByKey, showOnlyMatching, - showMatchingEventsFilter, - windowIdFilter + allowMatchingEventsFilter, + trackedWindow ): InspectorListItem[] => { - return filterInspectorListItems({ + const filteredItems = filterInspectorListItems({ allItems, - tab, miniFiltersByKey, - showMatchingEventsFilter, + allowMatchingEventsFilter, showOnlyMatching, - windowIdFilter, + trackedWindow, }) + // need to collapse adjacent inactivity items + // they look werong next to each other + return filteredItems.reduce((acc, item, index) => { + if (item.type === 'inactivity') { + const previousItem = filteredItems[index - 1] + if (previousItem?.type === 'inactivity') { + previousItem.durationMs += item.durationMs + return acc + } + } + acc.push(item) + return acc + }, [] as InspectorListItem[]) }, ], seekbarItems: [ (s) => [ s.allItems, - s.miniFiltersForTabByKey, + s.miniFiltersForTypeByKey, s.showOnlyMatching, - s.showMatchingEventsFilter, - s.windowIdFilter, + s.allowMatchingEventsFilter, + s.trackedWindow, ], ( allItems, - miniFiltersForTabByKey, + miniFiltersForTypeByKey, showOnlyMatching, - showMatchingEventsFilter, - windowIdFilter + allowMatchingEventsFilter, + trackedWindow ): (InspectorListItemEvent | InspectorListItemComment)[] => { - const eventsTabFilters = miniFiltersForTabByKey(SessionRecordingPlayerTab.EVENTS) - const eventTabFilteredItems = filterInspectorListItems({ + const eventFilteredItems = filterInspectorListItems({ allItems, - tab: SessionRecordingPlayerTab.EVENTS, - miniFiltersByKey: eventsTabFilters, - showMatchingEventsFilter, + miniFiltersByKey: miniFiltersForTypeByKey(FilterableInspectorListItemTypes.EVENTS), + allowMatchingEventsFilter, showOnlyMatching, - windowIdFilter, + trackedWindow, }) - let items: (InspectorListItemEvent | InspectorListItemComment)[] = eventTabFilteredItems.filter( + let items: (InspectorListItemEvent | InspectorListItemComment)[] = eventFilteredItems.filter( (item): item is InspectorListItemEvent | InspectorListItemComment => { - if (item.type === SessionRecordingPlayerTab.EVENTS) { - return !(showMatchingEventsFilter && showOnlyMatching && item.highlightColor !== 'primary') + if (item.type === FilterableInspectorListItemTypes.EVENTS) { + return !(allowMatchingEventsFilter && showOnlyMatching && item.highlightColor !== 'primary') } if (item.type === 'comment') { - return !showMatchingEventsFilter + return !allowMatchingEventsFilter } return false @@ -719,7 +817,7 @@ export const playerInspectorLogic = kea([ items = items.filter((item) => { const isPrimary = item.highlightColor === 'primary' const isPageView = - item.type === SessionRecordingPlayerTab.EVENTS && item.data.event === '$pageview' + item.type === FilterableInspectorListItemTypes.EVENTS && item.data.event === '$pageview' const isComment = item.type === 'comment' return isPrimary || isPageView || isComment }) @@ -733,7 +831,7 @@ export const playerInspectorLogic = kea([ }, ], - tabsState: [ + inspectorDataState: [ (s) => [ s.sessionEventsDataLoading, s.sessionPlayerMetaDataLoading, @@ -751,36 +849,31 @@ export const playerInspectorLogic = kea([ logs, performanceEvents, doctorEvents - ): Record => { - const tabEventsState = sessionEventsDataLoading ? 'loading' : events?.length ? 'ready' : 'empty' - const tabConsoleState = + ): Record => { + const dataForEventsState = sessionEventsDataLoading ? 'loading' : events?.length ? 'ready' : 'empty' + const dataForConsoleState = sessionPlayerMetaDataLoading || snapshotsLoading || !logs ? 'loading' : logs.length ? 'ready' : 'empty' - const tabNetworkState = + const dataForNetworkState = sessionPlayerMetaDataLoading || snapshotsLoading || !performanceEvents ? 'loading' : performanceEvents.length ? 'ready' : 'empty' - const tabDoctorState = + const dataForDoctorState = sessionPlayerMetaDataLoading || snapshotsLoading || !performanceEvents ? 'loading' : doctorEvents.length ? 'ready' : 'empty' return { - [SessionRecordingPlayerTab.ALL]: [tabEventsState, tabConsoleState, tabNetworkState].every( - (x) => x === 'loading' - ) - ? 'loading' - : 'ready', - [SessionRecordingPlayerTab.EVENTS]: tabEventsState, - [SessionRecordingPlayerTab.CONSOLE]: tabConsoleState, - [SessionRecordingPlayerTab.NETWORK]: tabNetworkState, - [SessionRecordingPlayerTab.DOCTOR]: tabDoctorState, + [FilterableInspectorListItemTypes.EVENTS]: dataForEventsState, + [FilterableInspectorListItemTypes.CONSOLE]: dataForConsoleState, + [FilterableInspectorListItemTypes.NETWORK]: dataForNetworkState, + [FilterableInspectorListItemTypes.DOCTOR]: dataForDoctorState, } }, ], @@ -825,15 +918,80 @@ export const playerInspectorLogic = kea([ return fuse.search(searchQuery).map((x: any) => x.item) }, ], + + /** + * All items by mini-filter key, not filtered items, so that we can count the unfiltered sets + */ + allItemsByMiniFilterKey: [ + (s) => [s.allItems, s.miniFiltersByKey], + (allItems, miniFiltersByKey): Record => { + const itemsByMiniFilterKey: Record = { + 'events-posthog': [], + 'events-custom': [], + 'events-pageview': [], + 'events-autocapture': [], + 'events-exceptions': [], + 'console-info': [], + 'console-warn': [], + 'console-error': [], + 'performance-fetch': [], + 'performance-document': [], + 'performance-assets-js': [], + 'performance-assets-css': [], + 'performance-assets-img': [], + 'performance-other': [], + doctor: [], + } + + for (const item of allItems) { + const miniFilter = itemToMiniFilter(item, miniFiltersByKey) + if (miniFilter) { + itemsByMiniFilterKey[miniFilter.key].push(item) + } + } + + return itemsByMiniFilterKey + }, + ], + + /** + * All items by item type, not filtered items, so that we can count the unfiltered sets + */ + allItemsByItemType: [ + (s) => [s.allItems], + (allItems): Record => { + const itemsByType: Record = { + [FilterableInspectorListItemTypes.EVENTS]: [], + [FilterableInspectorListItemTypes.CONSOLE]: [], + [FilterableInspectorListItemTypes.NETWORK]: [], + [FilterableInspectorListItemTypes.DOCTOR]: [], + context: [], + } + + for (const item of allItems) { + itemsByType[ + [ + FilterableInspectorListItemTypes.EVENTS, + FilterableInspectorListItemTypes.CONSOLE, + FilterableInspectorListItemTypes.NETWORK, + FilterableInspectorListItemTypes.DOCTOR, + ].includes(item.type as FilterableInspectorListItemTypes) + ? item.type + : 'context' + ].push(item) + } + + return itemsByType + }, + ], })), listeners(({ values, actions }) => ({ setItemExpanded: ({ index, expanded }) => { if (expanded) { - eventUsageLogic.actions.reportRecordingInspectorItemExpanded(values.tab, index) - const item = values.items[index] + eventUsageLogic.actions.reportRecordingInspectorItemExpanded(item.type, index) - if (item.type === SessionRecordingPlayerTab.EVENTS) { + if (item.type === FilterableInspectorListItemTypes.EVENTS) { actions.loadFullEventData(item.data) } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4c2ceab0faafe..b19486531a11c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -997,8 +997,7 @@ export enum SessionRecordingSidebarStacking { Horizontal = 'horizontal', } -export enum SessionRecordingPlayerTab { - ALL = 'all', +export enum FilterableInspectorListItemTypes { EVENTS = 'events', CONSOLE = 'console', NETWORK = 'network',