diff --git a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png index f23ba81faf0..54eef32ed15 100644 Binary files a/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png and b/frontend/__snapshots__/scenes-other-billing-v2--billing-v-2--light.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--heatmap--dark.png b/frontend/__snapshots__/scenes-other-toolbar--heatmap--dark.png index 0724cce928e..ca9cb5bc878 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--heatmap--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--heatmap--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--heatmap--light.png b/frontend/__snapshots__/scenes-other-toolbar--heatmap--light.png index 72c8963740f..1cefef9bc27 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--heatmap--light.png and b/frontend/__snapshots__/scenes-other-toolbar--heatmap--light.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--dark.png b/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--dark.png index 53dd61dfec0..dc8141139bf 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--light.png b/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--light.png index 1480c76289b..a02e8534103 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--light.png and b/frontend/__snapshots__/scenes-other-toolbar--heatmap-dark--light.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index fff3f335f74..4525b3c83b1 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -209,6 +209,7 @@ export const FEATURE_FLAGS = { SESSION_REPLAY_MOBILE_ONBOARDING: 'session-replay-mobile-onboarding', // owner: #team-replay IP_ALLOWLIST_SETTING: 'ip-allowlist-setting', // owner: @benjackwhite EMAIL_VERIFICATION_TICKET_SUBMISSION: 'email-verification-ticket-submission', // owner: #team-growth + TOOLBAR_HEATMAPS: 'toolbar-heatmaps', // owner: #team-replay THEME: 'theme', // owner: @aprilfools } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/loadPostHogJS.tsx b/frontend/src/loadPostHogJS.tsx index ccc37291143..a45ccdca06d 100644 --- a/frontend/src/loadPostHogJS.tsx +++ b/frontend/src/loadPostHogJS.tsx @@ -43,6 +43,9 @@ export function loadPostHogJS(): void { capture_copied_text: true, }, process_person: 'identified_only', + + __preview_heatmaps: true, + // Helper to capture events for assertions in Cypress _onCapture: (window as any)._cypress_posthog_captures ? (_, event) => (window as any)._cypress_posthog_captures.push(event) diff --git a/frontend/src/toolbar/actions/ActionsEditingToolbarMenu.tsx b/frontend/src/toolbar/actions/ActionsEditingToolbarMenu.tsx index 46120554d46..b60b0a381d7 100644 --- a/frontend/src/toolbar/actions/ActionsEditingToolbarMenu.tsx +++ b/frontend/src/toolbar/actions/ActionsEditingToolbarMenu.tsx @@ -1,4 +1,4 @@ -import { IconPencil, IconPlus, IconSearch, IconTrash, IconX } from '@posthog/icons' +import { IconPencil, IconPlus, IconSearch, IconTrash } from '@posthog/icons' import { LemonDivider, LemonTag } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { Field, Form, Group } from 'kea-forms' @@ -9,7 +9,7 @@ import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic' import { SelectorEditingModal } from '~/toolbar/actions/SelectorEditingModal' import { StepField } from '~/toolbar/actions/StepField' import { ToolbarMenu } from '~/toolbar/bar/ToolbarMenu' -import { posthog } from '~/toolbar/posthog' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' export const ActionsEditingToolbarMenu = (): JSX.Element => { const { @@ -38,7 +38,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => { startingSelector={editingSelectorValue} onChange={(selector) => { if (selector && editingSelector !== null) { - posthog.capture('toolbar_manual_selector_applied', { + toolbarPosthogJS.capture('toolbar_manual_selector_applied', { chosenSelector: selector, }) setElementSelector(selector, editingSelector) @@ -52,7 +52,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => { enableFormOnSubmit className="flex flex-col overflow-hidden flex-1" > - +

{selectedActionId === 'new' ? 'New ' : 'Edit '} action @@ -124,9 +124,12 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => { icon={} onClick={(e) => { e.stopPropagation() - posthog.capture('toolbar_manual_selector_modal_opened', { - selector: step?.selector, - }) + toolbarPosthogJS.capture( + 'toolbar_manual_selector_modal_opened', + { + selector: step?.selector, + } + ) editSelectorWithIndex(index) }} > @@ -198,22 +201,25 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => { - selectAction(null)} - sideIcon={} - > - Cancel - + {selectedActionId !== 'new' ? ( + } + size="small" + > + Delete + + ) : null} - + selectAction(null)}> + Cancel + + {selectedActionId === 'new' ? 'Create ' : 'Save '} action - {selectedActionId !== 'new' ? ( - } /> - ) : null} diff --git a/frontend/src/toolbar/actions/ActionsListView.tsx b/frontend/src/toolbar/actions/ActionsListView.tsx index fb999ed6009..db567ac44a0 100644 --- a/frontend/src/toolbar/actions/ActionsListView.tsx +++ b/frontend/src/toolbar/actions/ActionsListView.tsx @@ -23,7 +23,7 @@ export function ActionsListView({ actions }: ActionsListViewProps): JSX.Element subtle key={action.id} onClick={() => selectAction(action.id || null)} - className="font-medium my-1" + className="font-medium my-1 w-full" > {index + 1}. diff --git a/frontend/src/toolbar/actions/actionsTabLogic.tsx b/frontend/src/toolbar/actions/actionsTabLogic.tsx index dc49fa26042..7988a5384b1 100644 --- a/frontend/src/toolbar/actions/actionsTabLogic.tsx +++ b/frontend/src/toolbar/actions/actionsTabLogic.tsx @@ -7,8 +7,8 @@ import { urls } from 'scenes/urls' import { actionsLogic } from '~/toolbar/actions/actionsLogic' import { toolbarLogic } from '~/toolbar/bar/toolbarLogic' -import { posthog } from '~/toolbar/posthog' import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' import { ActionDraftType, ActionForm } from '~/toolbar/types' import { actionStepToActionStepFormItem, elementToActionStep, stepToDatabaseFormat } from '~/toolbar/utils' import { ActionType, ElementType } from '~/types' @@ -292,11 +292,11 @@ export const actionsTabLogic = kea([ } }, showButtonActions: () => { - posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: true }) + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: true }) }, hideButtonActions: () => { actions.setShowActionsTooltip(false) - posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: false }) + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: false }) }, [actionsLogic.actionTypes.getActionsSuccess]: () => { const { userIntent, actionId } = values diff --git a/frontend/src/toolbar/bar/ToolbarMenu.tsx b/frontend/src/toolbar/bar/ToolbarMenu.tsx index 31f21fefdc6..bbb4bbbabbe 100644 --- a/frontend/src/toolbar/bar/ToolbarMenu.tsx +++ b/frontend/src/toolbar/bar/ToolbarMenu.tsx @@ -1,15 +1,24 @@ -export function ToolbarMenu({ children }: { children: React.ReactNode }): JSX.Element { - return
{children}
+import clsx from 'clsx' + +export type ToolbarMenuProps = { + children: React.ReactNode + className?: string } -ToolbarMenu.Header = function ToolbarMenuHeader({ children }: { children: React.ReactNode }): JSX.Element { - return
{children}
+export function ToolbarMenu({ children, className }: ToolbarMenuProps): JSX.Element { + return
{children}
} -ToolbarMenu.Body = function ToolbarMenuBody({ children }: { children: React.ReactNode }): JSX.Element { - return
{children}
+ToolbarMenu.Header = function ToolbarMenuHeader({ children, className }: ToolbarMenuProps): JSX.Element { + return
{children}
} -ToolbarMenu.Footer = function ToolbarMenufooter({ children }: { children: React.ReactNode }): JSX.Element { - return
{children}
+ToolbarMenu.Body = function ToolbarMenuBody({ children, className }: ToolbarMenuProps): JSX.Element { + return ( +
{children}
+ ) +} + +ToolbarMenu.Footer = function ToolbarMenufooter({ children, className }: ToolbarMenuProps): JSX.Element { + return
{children}
} diff --git a/frontend/src/toolbar/elements/HeatmapElement.tsx b/frontend/src/toolbar/elements/AutocaptureElement.tsx similarity index 88% rename from frontend/src/toolbar/elements/HeatmapElement.tsx rename to frontend/src/toolbar/elements/AutocaptureElement.tsx index 61a0d5db7d6..e6b8588fbed 100644 --- a/frontend/src/toolbar/elements/HeatmapElement.tsx +++ b/frontend/src/toolbar/elements/AutocaptureElement.tsx @@ -1,6 +1,6 @@ import { ElementRect } from '~/toolbar/types' -interface HeatmapElementProps { +interface AutocaptureElementProps { rect?: ElementRect style: Record onClick: (event: React.MouseEvent) => void @@ -8,13 +8,13 @@ interface HeatmapElementProps { onMouseOut: (event: React.MouseEvent) => void } -export function HeatmapElement({ +export function AutocaptureElement({ rect, style = {}, onClick, onMouseOver, onMouseOut, -}: HeatmapElementProps): JSX.Element | null { +}: AutocaptureElementProps): JSX.Element | null { if (!rect) { return null } diff --git a/frontend/src/toolbar/elements/HeatmapLabel.tsx b/frontend/src/toolbar/elements/AutocaptureElementLabel.tsx similarity index 87% rename from frontend/src/toolbar/elements/HeatmapLabel.tsx rename to frontend/src/toolbar/elements/AutocaptureElementLabel.tsx index 5b1a77d301a..24b2aeb3f4d 100644 --- a/frontend/src/toolbar/elements/HeatmapLabel.tsx +++ b/frontend/src/toolbar/elements/AutocaptureElementLabel.tsx @@ -12,18 +12,18 @@ const heatmapLabelStyle = { fontFamily: 'monospace', } -interface HeatmapLabelProps extends React.PropsWithoutRef { +interface AutocaptureElementLabelProps extends React.PropsWithoutRef { rect?: ElementRect align?: 'left' | 'right' } -export function HeatmapLabel({ +export function AutocaptureElementLabel({ rect, style = {}, align = 'right', children, ...props -}: HeatmapLabelProps): JSX.Element | null { +}: AutocaptureElementLabelProps): JSX.Element | null { if (!rect) { return null } diff --git a/frontend/src/toolbar/elements/ElementInfoWindow.tsx b/frontend/src/toolbar/elements/ElementInfoWindow.tsx index 4d15961b7bc..1c0d010f754 100644 --- a/frontend/src/toolbar/elements/ElementInfoWindow.tsx +++ b/frontend/src/toolbar/elements/ElementInfoWindow.tsx @@ -81,7 +81,6 @@ export function ElementInfoWindow(): JSX.Element | null { transition: 'opacity 0.2s, box-shadow 0.2s', backgroundBlendMode: 'multiply', background: 'white', - boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`, }} > {onClose ? ( @@ -111,8 +110,16 @@ export function ElementInfoWindow(): JSX.Element | null { ) : null} - {/* eslint-disable-next-line react/forbid-dom-props */} -
+
diff --git a/frontend/src/toolbar/elements/Elements.tsx b/frontend/src/toolbar/elements/Elements.tsx index 0e8c0a278d6..3e57984535b 100644 --- a/frontend/src/toolbar/elements/Elements.tsx +++ b/frontend/src/toolbar/elements/Elements.tsx @@ -4,14 +4,17 @@ import { useActions, useValues } from 'kea' import { compactNumber } from 'lib/utils' import { Fragment } from 'react' +import { AutocaptureElement } from '~/toolbar/elements/AutocaptureElement' +import { AutocaptureElementLabel } from '~/toolbar/elements/AutocaptureElementLabel' import { ElementInfoWindow } from '~/toolbar/elements/ElementInfoWindow' import { elementsLogic } from '~/toolbar/elements/elementsLogic' import { FocusRect } from '~/toolbar/elements/FocusRect' -import { HeatmapElement } from '~/toolbar/elements/HeatmapElement' -import { HeatmapLabel } from '~/toolbar/elements/HeatmapLabel' import { heatmapLogic } from '~/toolbar/elements/heatmapLogic' import { getBoxColors, getHeatMapHue } from '~/toolbar/utils' +import { Heatmap } from './Heatmap' +import { ScrollDepth } from './ScrollDepth' + export function Elements(): JSX.Element { const { heatmapElements, @@ -48,16 +51,18 @@ export function Elements(): JSX.Element { zIndex: 2147483010, }} > + + {highlightElementMeta?.rect ? : null} {elementsToDisplay.map(({ rect, element }, index) => ( - selectElement(element)} @@ -76,14 +82,15 @@ export function Elements(): JSX.Element { {heatmapElements.map(({ rect, count, clickCount, rageclickCount, element }, index) => { return ( - selectedElement === null && setHoverElement(null)} /> {!!clickCount && ( - selectedElement === null && setHoverElement(null)} > {compactNumber(clickCount || 0)} - + )} {!!rageclickCount && ( - selectedElement === null && setHoverElement(null)} > {compactNumber(rageclickCount)}😡 - + )} ) @@ -162,7 +169,7 @@ export function Elements(): JSX.Element { {labelsToDisplay.map(({ element, rect, index }, loopIndex) => { if (rect) { return ( - selectedElement === null && setHoverElement(null)} > {(index || loopIndex) + 1} - + ) } })} diff --git a/frontend/src/toolbar/elements/Heatmap.tsx b/frontend/src/toolbar/elements/Heatmap.tsx new file mode 100644 index 00000000000..8c8bf5c797c --- /dev/null +++ b/frontend/src/toolbar/elements/Heatmap.tsx @@ -0,0 +1,46 @@ +import heatmapsJs, { Heatmap as HeatmapJS } from 'heatmap.js' +import { useValues } from 'kea' +import { useCallback, useEffect, useRef } from 'react' + +import { heatmapLogic } from '~/toolbar/elements/heatmapLogic' + +export function Heatmap(): JSX.Element | null { + const { heatmapJsData, heatmapEnabled, heatmapFilters } = useValues(heatmapLogic) + const heatmapsJsRef = useRef>() + const heatmapsJsContainerRef = useRef() + + const updateHeatmapData = useCallback((): void => { + try { + heatmapsJsRef.current?.setData(heatmapJsData) + } catch (e) { + console.error('error setting data', e) + } + }, [heatmapJsData]) + + const setHeatmapContainer = useCallback((container: HTMLDivElement | null): void => { + heatmapsJsContainerRef.current = container + if (!container) { + return + } + + heatmapsJsRef.current = heatmapsJs.create({ + container, + }) + + updateHeatmapData() + }, []) + + useEffect(() => { + updateHeatmapData() + }, [heatmapJsData]) + + if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type === 'scrolldepth') { + return null + } + + return ( +
+
+
+ ) +} diff --git a/frontend/src/toolbar/elements/ScrollDepth.tsx b/frontend/src/toolbar/elements/ScrollDepth.tsx new file mode 100644 index 00000000000..fe7aff4c2ea --- /dev/null +++ b/frontend/src/toolbar/elements/ScrollDepth.tsx @@ -0,0 +1,121 @@ +import { useValues } from 'kea' +import { useEffect, useState } from 'react' + +import { heatmapLogic } from '~/toolbar/elements/heatmapLogic' + +import { toolbarConfigLogic } from '../toolbarConfigLogic' + +function ScrollDepthMouseInfo(): JSX.Element | null { + const { posthog } = useValues(toolbarConfigLogic) + const { heatmapElements, rawHeatmapLoading } = useValues(heatmapLogic) + + // Track the mouse position and render an indicator about how many people have scrolled to this point + const [mouseY, setMouseY] = useState(0) + + useEffect(() => { + const onMove = (e: MouseEvent): void => { + setMouseY(e.clientY) + } + + window.addEventListener('mousemove', onMove) + return () => { + window.removeEventListener('mousemove', onMove) + } + }, []) + + if (!mouseY) { + return null + } + + const scrollOffset = (posthog as any).scrollManager.scrollY() + const scrolledMouseY = mouseY + scrollOffset + + const elementInMouseY = heatmapElements.find((x, i) => { + const lastY = heatmapElements[i - 1]?.y ?? 0 + return scrolledMouseY >= lastY && scrolledMouseY < x.y + }) + + const maxCount = heatmapElements[0]?.count ?? 0 + const percentage = ((elementInMouseY?.count ?? 0) / maxCount) * 100 + + return ( +
+
+
+ {rawHeatmapLoading ? ( + <>Loading... + ) : heatmapElements.length ? ( + <>{percentage.toPrecision(4)}% scrolled this far + ) : ( + <>No scroll data for the current dimension range + )} +
+ +
+
+ ) +} + +export function ScrollDepth(): JSX.Element | null { + const { posthog } = useValues(toolbarConfigLogic) + + const { heatmapEnabled, heatmapFilters, heatmapElements, scrollDepthPosthogJsError } = useValues(heatmapLogic) + + if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type !== 'scrolldepth') { + return null + } + + if (scrollDepthPosthogJsError) { + return null + } + + const scrollOffset = (posthog as any).scrollManager.scrollY() + + // We want to have a fading color from red to orange to green to blue to grey, fading from the highest count to the lowest + const maxCount = heatmapElements[0]?.count ?? 0 + + function color(count: number): string { + const value = 1 - count / maxCount + const safeValue = Math.max(0, Math.min(1, value)) + const hue = Math.round(260 * safeValue) + + // Return hsl color. You can adjust saturation and lightness to your liking + return `hsl(${hue}, 100%, 50%)` + } + + return ( +
+
+ {heatmapElements.map(({ y, count }, i) => ( +
+ ))} +
+ +
+ ) +} diff --git a/frontend/src/toolbar/elements/elementsLogic.ts b/frontend/src/toolbar/elements/elementsLogic.ts index df7ad663916..c08f0645fb7 100644 --- a/frontend/src/toolbar/elements/elementsLogic.ts +++ b/frontend/src/toolbar/elements/elementsLogic.ts @@ -3,9 +3,9 @@ import { collectAllElementsDeep } from 'query-selector-shadow-dom' import { actionsLogic } from '~/toolbar/actions/actionsLogic' import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic' -import { posthog } from '~/toolbar/posthog' import { currentPageLogic } from '~/toolbar/stats/currentPageLogic' import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' import { ActionElementWithMetadata, ElementWithMetadata } from '~/toolbar/types' import { elementToActionStep, getAllClickTargets, getElementForStep, getRectForElement } from '../utils' @@ -371,11 +371,11 @@ export const elementsLogic = kea([ }), listeners(({ actions }) => ({ enableInspect: () => { - posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: true }) + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: true }) actionsLogic.actions.getActions() }, disableInspect: () => { - posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: false }) + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: false }) }, selectElement: ({ element }) => { const inspectForAction = @@ -401,7 +401,7 @@ export const elementsLogic = kea([ } } - posthog.capture('toolbar selected HTML element', { + toolbarPosthogJS.capture('toolbar selected HTML element', { element_tag: element?.tagName.toLowerCase(), element_type: (element as HTMLInputElement)?.type, has_href: !!(element as HTMLAnchorElement)?.href, diff --git a/frontend/src/toolbar/elements/heatmapLogic.ts b/frontend/src/toolbar/elements/heatmapLogic.ts index 45ec1416304..840e61e39d0 100644 --- a/frontend/src/toolbar/elements/heatmapLogic.ts +++ b/frontend/src/toolbar/elements/heatmapLogic.ts @@ -1,30 +1,66 @@ import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { encodeParams } from 'kea-router' +import { subscriptions } from 'kea-subscriptions' +import { windowValues } from 'kea-window-values' import { elementToSelector, escapeRegex } from 'lib/actionUtils' import { PaginatedResponse } from 'lib/api' import { dateFilterToText } from 'lib/utils' +import { PostHog } from 'posthog-js' import { collectAllElementsDeep, querySelectorAllDeep } from 'query-selector-shadow-dom' -import { posthog } from '~/toolbar/posthog' import { currentPageLogic } from '~/toolbar/stats/currentPageLogic' import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic' -import { CountedHTMLElement, ElementsEventType } from '~/toolbar/types' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' +import { + CountedHTMLElement, + ElementsEventType, + HeatmapElement, + HeatmapRequestType, + HeatmapResponseType, +} from '~/toolbar/types' import { elementToActionStep, trimElement } from '~/toolbar/utils' import { FilterType, PropertyFilterType, PropertyOperator } from '~/types' import type { heatmapLogicType } from './heatmapLogicType' +export const SCROLL_DEPTH_JS_VERSION = [1, 99] + const emptyElementsStatsPages: PaginatedResponse = { next: undefined, previous: undefined, results: [], } +export type CommonFilters = { + date_from?: string + date_to?: string +} + +export type HeatmapFilters = { + enabled: boolean + type?: string + viewportAccuracy?: number + aggregation?: HeatmapRequestType['aggregation'] +} + +export type HeatmapJsDataPoint = { + x: number + y: number + value: number +} + +export type HeatmapJsData = { + data: HeatmapJsDataPoint[] + max: number + min: number +} +export type HeatmapFixedPositionMode = 'fixed' | 'relative' | 'hidden' + export const heatmapLogic = kea([ path(['toolbar', 'elements', 'heatmapLogic']), connect({ - values: [currentPageLogic, ['href', 'wildcardHref']], + values: [currentPageLogic, ['href', 'wildcardHref'], toolbarConfigLogic, ['posthog']], actions: [currentPageLogic, ['setHref', 'setWildcardHref']], }), actions({ @@ -33,12 +69,27 @@ export const heatmapLogic = kea([ }), enableHeatmap: true, disableHeatmap: true, - setShowHeatmapTooltip: (showHeatmapTooltip: boolean) => ({ showHeatmapTooltip }), setShiftPressed: (shiftPressed: boolean) => ({ shiftPressed }), - setHeatmapFilter: (filter: Partial) => ({ filter }), + setCommonFilters: (filters: CommonFilters) => ({ filters }), + setHeatmapFilters: (filters: HeatmapFilters) => ({ filters }), + patchHeatmapFilters: (filters: Partial) => ({ filters }), + toggleClickmapsEnabled: (enabled?: boolean) => ({ enabled }), + loadMoreElementStats: true, setMatchLinksByHref: (matchLinksByHref: boolean) => ({ matchLinksByHref }), + loadHeatmap: (type: string) => ({ + type, + }), + loadAllEnabled: (delayMs: number = 0) => ({ delayMs }), + maybeLoadClickmap: (delayMs: number = 0) => ({ delayMs }), + maybeLoadHeatmap: (delayMs: number = 0) => ({ delayMs }), + fetchHeatmapApi: (params: HeatmapRequestType) => ({ params }), + setHeatmapScrollY: (scrollY: number) => ({ scrollY }), + setHeatmapFixedPositionMode: (mode: HeatmapFixedPositionMode) => ({ mode }), }), + windowValues(() => ({ + windowWidth: (window: Window) => window.innerWidth, + })), reducers({ matchLinksByHref: [false, { setMatchLinksByHref: (_, { matchLinksByHref }) => matchLinksByHref }], canLoadMoreElementStats: [ @@ -56,31 +107,49 @@ export const heatmapLogic = kea([ getElementStatsFailure: () => false, }, ], - heatmapLoading: [ - false, - { - getElementStats: () => true, - getElementStatsSuccess: () => false, - getElementStatsFailure: () => false, - resetElementStats: () => false, - }, - ], - showHeatmapTooltip: [ - false, - { - setShowHeatmapTooltip: (_, { showHeatmapTooltip }) => showHeatmapTooltip, - }, - ], shiftPressed: [ false, { setShiftPressed: (_, { shiftPressed }) => shiftPressed, }, ], - heatmapFilter: [ - {} as Partial, + commonFilters: [ + {} as CommonFilters, { - setHeatmapFilter: (_, { filter }) => filter, + setCommonFilters: (_, { filters }) => filters, + }, + ], + heatmapFilters: [ + { + enabled: true, + type: 'click', + viewportAccuracy: 0.9, + aggregation: 'total_count', + } as HeatmapFilters, + { persist: true }, + { + setHeatmapFilters: (_, { filters }) => filters, + patchHeatmapFilters: (state, { filters }) => ({ ...state, ...filters }), + }, + ], + clickmapsEnabled: [ + true, + { persist: true }, + { + toggleClickmapsEnabled: (state, { enabled }) => (enabled === undefined ? !state : enabled), + }, + ], + heatmapScrollY: [ + 0, + { + setHeatmapScrollY: (_, { scrollY }) => scrollY, + }, + ], + + heatmapFixedPositionMode: [ + 'fixed' as HeatmapFixedPositionMode, + { + setHeatmapFixedPositionMode: (_, { mode }) => mode, }, ], }), @@ -110,7 +179,8 @@ export const heatmapLogic = kea([ type: PropertyFilterType.Event, }, ], - ...values.heatmapFilter, + date_from: values.commonFilters.date_from, + date_to: values.commonFilters.date_to, } defaultUrl = `/api/element/stats/${encodeParams({ ...params, paginate_response: true }, '?')}` @@ -144,22 +214,58 @@ export const heatmapLogic = kea([ }, }, ], + + rawHeatmap: [ + null as HeatmapResponseType | null, + { + loadHeatmap: async () => { + const { href, wildcardHref } = values + const { date_from, date_to } = values.commonFilters + const { type, aggregation } = values.heatmapFilters + const urlExact = wildcardHref === href ? href : undefined + const urlRegex = wildcardHref !== href ? wildcardHref : undefined + + // toolbar fetch collapses queryparams but this URL has multiple with the same name + const response = await toolbarFetch( + `/api/heatmap/${encodeParams( + { + type, + date_from, + date_to, + url_exact: urlExact, + url_pattern: urlRegex, + viewport_width_min: values.viewportRange.min, + viewport_width_max: values.viewportRange.max, + aggregation, + }, + '?' + )}`, + 'GET' + ) + + if (response.status === 403) { + toolbarConfigLogic.actions.authenticate() + } + + if (response.status !== 200) { + throw new Error('API error') + } + + return await response.json() + }, + }, + ], })), selectors(({ cache }) => ({ dateRange: [ - (s) => [s.heatmapFilter], - (heatmapFilter: Partial) => { - return dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days') + (s) => [s.commonFilters], + (commonFilters: Partial) => { + return dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days') }, ], elements: [ - (selectors) => [ - selectors.elementStats, - toolbarConfigLogic.selectors.dataAttributes, - selectors.href, - selectors.matchLinksByHref, - ], + (s) => [s.elementStats, toolbarConfigLogic.selectors.dataAttributes, s.href, s.matchLinksByHref], (elementStats, dataAttributes, href, matchLinksByHref) => { cache.pageElements = cache.lastHref == href ? cache.pageElements : collectAllElementsDeep('*', document) cache.selectorToElements = cache.lastHref == href ? cache.selectorToElements : {} @@ -240,8 +346,11 @@ export const heatmapLogic = kea([ }, ], countedElements: [ - (selectors) => [selectors.elements, toolbarConfigLogic.selectors.dataAttributes], - (elements, dataAttributes) => { + (s) => [s.elements, toolbarConfigLogic.selectors.dataAttributes, s.clickmapsEnabled], + (elements, dataAttributes, clickmapsEnabled) => { + if (!clickmapsEnabled) { + return [] + } const normalisedElements = new Map() ;(elements || []).forEach((countedElement) => { const trimmedElement = trimElement(countedElement.element) @@ -273,22 +382,225 @@ export const heatmapLogic = kea([ return countedElements.map((e, i) => ({ ...e, position: i + 1 })) }, ], - elementCount: [(selectors) => [selectors.countedElements], (countedElements) => countedElements.length], + elementCount: [(s) => [s.countedElements], (countedElements) => countedElements.length], clickCount: [ - (selectors) => [selectors.countedElements], + (s) => [s.countedElements], (countedElements) => (countedElements ? countedElements.map((e) => e.count).reduce((a, b) => a + b, 0) : 0), ], highestClickCount: [ - (selectors) => [selectors.countedElements], + (s) => [s.countedElements], (countedElements) => countedElements ? countedElements.map((e) => e.count).reduce((a, b) => (b > a ? b : a), 0) : 0, ], + + heatmapElements: [ + (s) => [s.rawHeatmap], + (rawHeatmap): HeatmapElement[] => { + if (!rawHeatmap) { + return [] + } + + const elements: HeatmapElement[] = [] + + rawHeatmap?.results.forEach((element) => { + if ('scroll_depth_bucket' in element) { + elements.push({ + count: element.cumulative_count, + xPercentage: 0, + targetFixed: false, + y: element.scroll_depth_bucket, + }) + } else { + elements.push({ + count: element.count, + xPercentage: element.pointer_relative_x, + targetFixed: element.pointer_target_fixed, + y: element.pointer_y, + }) + } + }) + + return elements + }, + ], + + viewportRange: [ + (s) => [s.heatmapFilters, s.windowWidth], + (heatmapFilters, windowWidth): { max: number; min: number } => { + const viewportAccuracy = heatmapFilters.viewportAccuracy ?? 0.2 + const extraPixels = windowWidth - windowWidth * viewportAccuracy + + const minWidth = Math.max(0, windowWidth - extraPixels) + const maxWidth = windowWidth + extraPixels + + return { + min: Math.round(minWidth), + max: Math.round(maxWidth), + } + }, + ], + + scrollDepthPosthogJsError: [ + (s) => [s.posthog], + (posthog: PostHog): 'version' | 'disabled' | null => { + const posthogVersion = posthog?._calculate_event_properties('test', {})?.['$lib_version'] ?? '0.0.0' + const majorMinorVersion = posthogVersion.split('.') + const majorVersion = parseInt(majorMinorVersion[0], 10) + const minorVersion = parseInt(majorMinorVersion[1], 10) + + if (!(posthog as any)?.scrollManager?.scrollY) { + return 'version' + } + + const isSupported = + majorVersion > SCROLL_DEPTH_JS_VERSION[0] || + (majorVersion === SCROLL_DEPTH_JS_VERSION[0] && minorVersion >= SCROLL_DEPTH_JS_VERSION[1]) + const isDisabled = posthog?.config.disable_scroll_properties + + return !isSupported ? 'version' : isDisabled ? 'disabled' : null + }, + ], + + heatmapJsData: [ + (s) => [s.heatmapElements, s.heatmapScrollY, s.windowWidth, s.heatmapFixedPositionMode], + (heatmapElements, heatmapScrollY, windowWidth, heatmapFixedPositionMode): HeatmapJsData => { + // We want to account for all the fixed position elements, the scroll of the context and the browser width + const data = heatmapElements.reduce((acc, element) => { + if (heatmapFixedPositionMode === 'hidden' && element.targetFixed) { + return acc + } + + const y = Math.round( + element.targetFixed && heatmapFixedPositionMode === 'fixed' + ? element.y + : element.y - heatmapScrollY + ) + const x = Math.round(element.xPercentage * windowWidth) + + return [...acc, { x, y, value: element.count }] + }, [] as HeatmapJsDataPoint[]) + + // Max is the highest value in the data set we have + const max = data.reduce((max, { value }) => Math.max(max, value), 0) + + // TODO: Group based on some sensible resolutions (we can then use this for a hover state to show more detail) + + return { + min: 0, + max, + data, + } + }, + ], + })), + + subscriptions(({ actions }) => ({ + viewportRange: () => { + actions.maybeLoadHeatmap(500) + }, + })), + + listeners(({ actions, values }) => ({ + fetchHeatmapApi: async () => { + const { href, wildcardHref } = values + const { date_from, date_to } = values.commonFilters + const { type, aggregation } = values.heatmapFilters + const urlExact = wildcardHref === href ? href : undefined + const urlRegex = wildcardHref !== href ? wildcardHref : undefined + + // toolbar fetch collapses queryparams but this URL has multiple with the same name + const response = await toolbarFetch( + `/api/heatmap/${encodeParams( + { + type, + date_from, + date_to, + url_exact: urlExact, + url_pattern: urlRegex, + viewport_width_min: values.viewportRange.min, + viewport_width_max: values.viewportRange.max, + aggregation, + }, + '?' + )}`, + 'GET' + ) + + if (response.status === 403) { + toolbarConfigLogic.actions.authenticate() + } + + if (response.status !== 200) { + throw new Error('API error') + } + + return await response.json() + }, + enableHeatmap: () => { + actions.loadAllEnabled() + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true }) + }, + disableHeatmap: () => { + actions.resetElementStats() + toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false }) + }, + + loadAllEnabled: async ({ delayMs }, breakpoint) => { + await breakpoint(delayMs) + + actions.maybeLoadHeatmap() + actions.maybeLoadClickmap() + }, + maybeLoadClickmap: async ({ delayMs }, breakpoint) => { + await breakpoint(delayMs) + if (values.heatmapEnabled && values.clickmapsEnabled) { + actions.getElementStats() + } + }, + + maybeLoadHeatmap: async ({ delayMs }, breakpoint) => { + await breakpoint(delayMs) + if (values.heatmapEnabled) { + if (values.heatmapFilters.enabled && values.heatmapFilters.type) { + actions.loadHeatmap(values.heatmapFilters.type) + } + } + }, + + setHref: () => { + actions.loadAllEnabled() + }, + setWildcardHref: () => { + actions.loadAllEnabled(1000) + }, + setCommonFilters: () => { + actions.loadAllEnabled(200) + }, + + // Only trigger element stats loading if clickmaps are enabled + toggleClickmapsEnabled: () => { + if (values.clickmapsEnabled) { + actions.getElementStats() + } + }, + + loadMoreElementStats: () => { + if (values.elementStats?.next) { + actions.getElementStats(values.elementStats.next) + } + }, + + patchHeatmapFilters: ({ filters }) => { + if (filters.type) { + // Clear the heatmap if the type changes + actions.loadHeatmapSuccess({ results: [] }) + } + actions.maybeLoadHeatmap(200) + }, })), afterMount(({ actions, values, cache }) => { - if (values.heatmapEnabled) { - actions.getElementStats() - } + actions.loadAllEnabled() cache.keyDownListener = (event: KeyboardEvent) => { if (event.shiftKey && !values.shiftPressed) { actions.setShiftPressed(true) @@ -301,53 +613,18 @@ export const heatmapLogic = kea([ } window.addEventListener('keydown', cache.keyDownListener) window.addEventListener('keyup', cache.keyUpListener) + + cache.scrollCheckTimer = setInterval(() => { + const scrollY = (values.posthog as any)?.scrollManager?.scrollY() ?? 0 + if (values.heatmapScrollY !== scrollY) { + actions.setHeatmapScrollY(scrollY) + } + }, 100) }), beforeUnmount(({ cache }) => { window.removeEventListener('keydown', cache.keyDownListener) window.removeEventListener('keyup', cache.keyUpListener) + clearInterval(cache.scrollCheckTimer) }), - - listeners(({ actions, values }) => ({ - loadMoreElementStats: () => { - if (values.elementStats?.next) { - actions.getElementStats(values.elementStats.next) - } - }, - setHref: () => { - if (values.heatmapEnabled) { - actions.resetElementStats() - actions.getElementStats() - } - }, - setWildcardHref: async (_, breakpoint) => { - await breakpoint(100) - if (values.heatmapEnabled) { - actions.resetElementStats() - actions.getElementStats() - } - }, - enableHeatmap: () => { - actions.getElementStats() - posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true }) - }, - disableHeatmap: () => { - actions.resetElementStats() - actions.setShowHeatmapTooltip(false) - posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false }) - }, - getElementStatsSuccess: () => { - actions.setShowHeatmapTooltip(true) - }, - setShowHeatmapTooltip: async ({ showHeatmapTooltip }, breakpoint) => { - if (showHeatmapTooltip) { - await breakpoint(1000) - actions.setShowHeatmapTooltip(false) - } - }, - setHeatmapFilter: () => { - actions.resetElementStats() - actions.getElementStats() - }, - })), ]) diff --git a/frontend/src/toolbar/flags/flagsToolbarLogic.ts b/frontend/src/toolbar/flags/flagsToolbarLogic.ts index e1f41cabca7..07e70826460 100644 --- a/frontend/src/toolbar/flags/flagsToolbarLogic.ts +++ b/frontend/src/toolbar/flags/flagsToolbarLogic.ts @@ -5,8 +5,8 @@ import { encodeParams } from 'kea-router' import { permanentlyMount } from 'lib/utils/kea-logic-builders' import type { PostHog } from 'posthog-js' -import { posthog as posthogJS } from '~/toolbar/posthog' import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' import { CombinedFeatureFlagAndValueType } from '~/types' import type { flagsToolbarLogicType } from './flagsToolbarLogicType' @@ -119,7 +119,7 @@ export const flagsToolbarLogic = kea([ const clientPostHog = values.posthog if (clientPostHog) { clientPostHog.featureFlags.override({ ...values.localOverrides, [flagKey]: overrideValue }) - posthogJS.capture('toolbar feature flag overridden') + toolbarPosthogJS.capture('toolbar feature flag overridden') actions.checkLocalOverrides() clientPostHog.featureFlags.reloadFeatureFlags() } @@ -134,7 +134,7 @@ export const flagsToolbarLogic = kea([ } else { clientPostHog.featureFlags.override(false) } - posthogJS.capture('toolbar feature flag override removed') + toolbarPosthogJS.capture('toolbar feature flag override removed') actions.checkLocalOverrides() clientPostHog.featureFlags.reloadFeatureFlags() } diff --git a/frontend/src/toolbar/posthog.ts b/frontend/src/toolbar/posthog.ts deleted file mode 100644 index 6be5aefd4a9..00000000000 --- a/frontend/src/toolbar/posthog.ts +++ /dev/null @@ -1,18 +0,0 @@ -import PostHog from 'posthog-js-lite' - -const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg' - -const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT -const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY -const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com' - -export const posthog = new PostHog(apiKey || DEFAULT_API_KEY, { - host: apiHost, - enable: false, // must call .optIn() before any events are sent - persistence: 'memory', // We don't want to persist anything, all events are in-memory - persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence -}) - -if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) { - posthog.debug() -} diff --git a/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx b/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx index e0af4c02d18..273796a853a 100644 --- a/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx +++ b/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx @@ -1,9 +1,12 @@ +import { IconMagicWand } from '@posthog/icons' +import { LemonLabel, LemonSegmentedButton } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { CUSTOM_OPTION_KEY } from 'lib/components/DateFilter/types' import { IconSync } from 'lib/lemon-ui/icons' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonInput } from 'lib/lemon-ui/LemonInput' import { LemonMenu } from 'lib/lemon-ui/LemonMenu' +import { LemonSlider } from 'lib/lemon-ui/LemonSlider' import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch' import { Spinner } from 'lib/lemon-ui/Spinner' import { Tooltip } from 'lib/lemon-ui/Tooltip' @@ -14,98 +17,326 @@ import { elementsLogic } from '~/toolbar/elements/elementsLogic' import { heatmapLogic } from '~/toolbar/elements/heatmapLogic' import { currentPageLogic } from '~/toolbar/stats/currentPageLogic' +import { useToolbarFeatureFlag } from '../toolbarPosthogJS' + +const ScrollDepthJSWarning = (): JSX.Element | null => { + const { scrollDepthPosthogJsError } = useValues(heatmapLogic) + + if (!scrollDepthPosthogJsError) { + return null + } + + return ( +

+ {scrollDepthPosthogJsError === 'version' ? ( + <>This feature requires a newer version of posthog-js + ) : scrollDepthPosthogJsError === 'disabled' ? ( + <> + Your posthog-js config has disable_scroll_properties set - these properties are required for + scroll depth calculations to work. + + ) : null} +

+ ) +} + export const HeatmapToolbarMenu = (): JSX.Element => { const { wildcardHref } = useValues(currentPageLogic) - const { setWildcardHref } = useActions(currentPageLogic) + const { setWildcardHref, autoWildcardHref } = useActions(currentPageLogic) - const { matchLinksByHref, countedElements, clickCount, heatmapLoading, heatmapFilter, canLoadMoreElementStats } = - useValues(heatmapLogic) - const { setHeatmapFilter, loadMoreElementStats, setMatchLinksByHref } = useActions(heatmapLogic) + const { + matchLinksByHref, + countedElements, + clickCount, + commonFilters, + heatmapFilters, + canLoadMoreElementStats, + viewportRange, + rawHeatmapLoading, + elementStatsLoading, + clickmapsEnabled, + heatmapFixedPositionMode, + } = useValues(heatmapLogic) + const { + setCommonFilters, + patchHeatmapFilters, + loadMoreElementStats, + setMatchLinksByHref, + toggleClickmapsEnabled, + setHeatmapFixedPositionMode, + } = useActions(heatmapLogic) const { setHighlightElement, setSelectedElement } = useActions(elementsLogic) const dateItems = dateMapping .filter((dm) => dm.key !== CUSTOM_OPTION_KEY) .map((dateOption) => ({ label: dateOption.key, - onClick: () => setHeatmapFilter({ date_from: dateOption.values[0], date_to: dateOption.values[1] }), + onClick: () => setCommonFilters({ date_from: dateOption.values[0], date_to: dateOption.values[1] }), })) + const showNewHeatmaps = useToolbarFeatureFlag('toolbar-heatmaps') + return ( - -
-
Use * as a wildcard
-
- - - {dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')} - - +
+ + } + size="small" + onClick={() => autoWildcardHref()} + tooltip={ + <> + You can use the wildcard character * to match any character in the URL. For + example, https://example.com/* will match{' '} + https://example.com/page and https://example.com/page/1. +
+ Click this button to automatically wildcards where we believe it would make sense + + } + /> +
- } - type="secondary" - size="small" - onClick={loadMoreElementStats} - disabledReason={ - canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.' - } - > - Load more +
+ + + {dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')} - - {heatmapLoading ? : null} -
-
- Found: {countedElements.length} elements / {clickCount} clicks! -
- - - setMatchLinksByHref(checked)} - fullWidth={true} - bordered={true} - /> - +
-
-
- {heatmapLoading ? ( - - - - ) : countedElements.length ? ( - countedElements.map(({ element, count, actionStep }, index) => { - return ( -
setSelectedElement(element)} - onMouseEnter={() => setHighlightElement(element)} - onMouseLeave={() => setHighlightElement(null)} - > -
- {index + 1}.  - {actionStep?.text || - (actionStep?.tag_name ? ( - <{actionStep.tag_name}> - ) : ( - Element - ))} -
-
{count} clicks
+ {showNewHeatmaps ? ( +
+ Heatmaps {rawHeatmapLoading ? : null}} + onChange={(e) => + patchHeatmapFilters({ + enabled: e, + }) + } + /> + + {heatmapFilters.enabled && ( + <> +

+ Heatmaps are calculated using additional data sent along with standard events. They + are based off of general pointer interactions and might not be 100% accurate to the + page you are viewing. +

+
+ Heatmap type +
+ patchHeatmapFilters({ type: e })} + value={heatmapFilters.type ?? undefined} + options={[ + { + value: 'click', + label: 'Clicks', + }, + { + value: 'rageclick', + label: 'Rageclicks', + }, + { + value: 'mousemove', + label: 'Mouse moves', + }, + { + value: 'scrolldepth', + label: 'Scroll depth', + }, + ]} + size="small" + />
- ) - }) - ) : ( -
No elements found.
+ + {heatmapFilters.type === 'scrolldepth' && ( + <> +

+ Scroll depth uses additional information from Pageview and Pageleave + events to indicate how far down the page users have scrolled. +

+ + + )} + + Aggregation +
+ patchHeatmapFilters({ aggregation: e })} + value={heatmapFilters.aggregation ?? 'total_count'} + options={[ + { + value: 'total_count', + label: 'Total count', + }, + { + value: 'unique_visitors', + label: 'Unique visitors', + }, + ]} + size="small" + /> +
+ + Viewport accuracy +
+ patchHeatmapFilters({ viewportAccuracy: value })} + /> + + + {`${Math.round((heatmapFilters.viewportAccuracy ?? 1) * 100)}% (${ + viewportRange.min + }px - ${viewportRange.max}px)`} + + +
+ + {heatmapFilters.type !== 'scrolldepth' ? ( + <> + Fixed positioning calculation +

+ PostHog JS will attempt to detect fixed elements such as headers or + modals and will therefore show those heatmap areas, ignoring the scroll + value. +
+ You can choose to show these areas as fixed, include them with scrolled + data or hide them altogether. +

+ + + + ) : null} +
+ )}
+ ) : null} + +
+ {showNewHeatmaps ? ( + Clickmaps (autocapture) {elementStatsLoading ? : null}} + onChange={(e) => toggleClickmapsEnabled(e)} + /> + ) : null} + + {(clickmapsEnabled || !showNewHeatmaps) && ( + <> + {showNewHeatmaps ? ( +

+ Clickmaps are built using Autocapture events. They are more accurate than heatmaps + if the event can be mapped to a specific element found on the page you are viewing + but less data is usually captured. +

+ ) : null} +
+ } + type="secondary" + size="small" + onClick={loadMoreElementStats} + disabledReason={ + canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.' + } + > + Load more + + + Matching links by their target URL can exclude clicks from the heatmap if + the URL is too unique. + + } + > + setMatchLinksByHref(checked)} + fullWidth={true} + bordered={true} + /> + +
+ +
+ Found: {countedElements.length} elements / {clickCount} clicks! +
+
+ {countedElements.length ? ( + countedElements.map(({ element, count, actionStep }, index) => { + return ( + setSelectedElement(element)} + > +
setHighlightElement(element)} + onMouseLeave={() => setHighlightElement(null)} + > +
+ {index + 1}.  + {actionStep?.text || + (actionStep?.tag_name ? ( + <{actionStep.tag_name}> + ) : ( + Element + ))} +
+
{count} clicks
+
+
+ ) + }) + ) : ( +
No elements found.
+ )} +
+ + )}
diff --git a/frontend/src/toolbar/stats/currentPageLogic.ts b/frontend/src/toolbar/stats/currentPageLogic.ts index e867653432c..021f9cea56d 100644 --- a/frontend/src/toolbar/stats/currentPageLogic.ts +++ b/frontend/src/toolbar/stats/currentPageLogic.ts @@ -1,12 +1,32 @@ -import { actions, afterMount, beforeUnmount, kea, path, reducers } from 'kea' +import { actions, afterMount, beforeUnmount, kea, listeners, path, reducers } from 'kea' import type { currentPageLogicType } from './currentPageLogicType' +const replaceWithWildcard = (part: string): string => { + // replace uuids + if (part.match(/^([a-f]|[0-9]){8}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){12}$/)) { + return '*' + } + + // replace digits + if (part.match(/^[0-9]+$/)) { + return '*' + } + + // Replace long values + if (part.length > 24) { + return '*' + } + + return part +} + export const currentPageLogic = kea([ path(['toolbar', 'stats', 'currentPageLogic']), actions(() => ({ setHref: (href: string) => ({ href }), setWildcardHref: (href: string) => ({ href }), + autoWildcardHref: true, })), reducers(() => ({ href: [window.location.href, { setHref: (_, { href }) => href }], @@ -16,6 +36,31 @@ export const currentPageLogic = kea([ ], })), + listeners(({ actions, values }) => ({ + autoWildcardHref: () => { + let url = values.wildcardHref + + const urlParts = url.split('?') + + url = urlParts[0] + .split('/') + .map((part) => replaceWithWildcard(part)) + .join('/') + + // Iterate over query params and do the same for their values + if (urlParts.length > 1) { + const queryParams = urlParts[1].split('&') + for (let i = 0; i < queryParams.length; i++) { + const [key, value] = queryParams[i].split('=') + queryParams[i] = `${key}=${replaceWithWildcard(value)}` + } + url = `${url}?${queryParams.join('&')}` + } + + actions.setWildcardHref(url) + }, + })), + afterMount(({ actions, values, cache }) => { cache.interval = window.setInterval(() => { if (window.location.href !== values.href) { diff --git a/frontend/src/toolbar/toolbarConfigLogic.ts b/frontend/src/toolbar/toolbarConfigLogic.ts index c0650d7552d..55ea5b53684 100644 --- a/frontend/src/toolbar/toolbarConfigLogic.ts +++ b/frontend/src/toolbar/toolbarConfigLogic.ts @@ -2,7 +2,7 @@ import { actions, afterMount, kea, listeners, path, props, reducers, selectors } import { combineUrl, encodeParams } from 'kea-router' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' -import { posthog } from '~/toolbar/posthog' +import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS' import { ToolbarProps } from '~/types' import type { toolbarConfigLogicType } from './toolbarConfigLogicType' @@ -51,17 +51,17 @@ export const toolbarConfigLogic = kea([ listeners(({ values, actions }) => ({ authenticate: () => { - posthog.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated }) + toolbarPosthogJS.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated }) const encodedUrl = encodeURIComponent(window.location.href) actions.persistConfig() window.location.href = `${values.apiURL}/authorize_and_redirect/?redirect=${encodedUrl}` }, logout: () => { - posthog.capture('toolbar logout') + toolbarPosthogJS.capture('toolbar logout') localStorage.removeItem(LOCALSTORAGE_KEY) }, tokenExpired: () => { - posthog.capture('toolbar token expired') + toolbarPosthogJS.capture('toolbar token expired') console.warn('PostHog Toolbar API token expired. Clearing session.') if (values.props.source !== 'localstorage') { lemonToast.error('PostHog Toolbar API token expired.') @@ -87,12 +87,14 @@ export const toolbarConfigLogic = kea([ afterMount(({ props, values }) => { if (props.instrument) { const distinctId = props.distinctId + + void toolbarPosthogJS.optIn() + if (distinctId) { - posthog.identify(distinctId, props.userEmail ? { email: props.userEmail } : {}) + toolbarPosthogJS.identify(distinctId, props.userEmail ? { email: props.userEmail } : {}) } - posthog.optIn() } - posthog.capture('toolbar loaded', { is_authenticated: values.isAuthenticated }) + toolbarPosthogJS.capture('toolbar loaded', { is_authenticated: values.isAuthenticated }) }), ]) diff --git a/frontend/src/toolbar/toolbarPosthogJS.ts b/frontend/src/toolbar/toolbarPosthogJS.ts new file mode 100644 index 00000000000..cb8cf83d2bd --- /dev/null +++ b/frontend/src/toolbar/toolbarPosthogJS.ts @@ -0,0 +1,35 @@ +import { FeatureFlagKey } from 'lib/constants' +import PostHog from 'posthog-js-lite' +import { useEffect, useState } from 'react' + +const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg' + +const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT +const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY +const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com' + +export const toolbarPosthogJS = new PostHog(apiKey || DEFAULT_API_KEY, { + host: apiHost, + defaultOptIn: false, // must call .optIn() before any events are sent + persistence: 'memory', // We don't want to persist anything, all events are in-memory + persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence + preloadFeatureFlags: false, +}) + +if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) { + toolbarPosthogJS.debug() +} + +export const useToolbarFeatureFlag = (flag: FeatureFlagKey, match?: string): boolean => { + const [flagValue, setFlagValue] = useState(toolbarPosthogJS.getFeatureFlag(flag)) + + useEffect(() => { + return toolbarPosthogJS.onFeatureFlag(flag, (value) => setFlagValue(value)) + }, [flag, match]) + + if (match) { + return flagValue === match + } + + return !!flagValue +} diff --git a/frontend/src/toolbar/types.ts b/frontend/src/toolbar/types.ts index acc0a13a100..261a9618290 100644 --- a/frontend/src/toolbar/types.ts +++ b/frontend/src/toolbar/types.ts @@ -7,6 +7,42 @@ export type ElementsEventType = { type: '$autocapture' | '$rageclick' } +export type HeatmapKind = 'click' | 'rageclick' | 'mousemove' | 'scrolldepth' + +export type HeatmapRequestType = { + type: HeatmapKind + date_from?: string + date_to?: string + url_exact?: string + url_pattern?: string + viewport_width_min?: number + viewport_width_max?: number + aggregation: 'total_count' | 'unique_visitors' +} + +export type HeatmapResponseType = { + results: ( + | { + count: number + pointer_relative_x: number + pointer_target_fixed: boolean + pointer_y: number + } + | { + scroll_depth_bucket: number + bucket_count: number + cumulative_count: number + } + )[] +} + +export type HeatmapElement = { + count: number + xPercentage: number + targetFixed: boolean + y: number +} + export interface CountedHTMLElement { count: number // total of types of clicks clickCount: number // autocapture clicks @@ -44,12 +80,6 @@ export interface ActionElementWithMetadata extends ElementWithMetadata { step?: ActionStepType } -export type BoxColor = { - backgroundBlendMode: string - background: string - boxShadow: string -} - export type ActionDraftType = Omit export interface ActionStepForm extends ActionStepType { diff --git a/frontend/src/toolbar/utils.ts b/frontend/src/toolbar/utils.ts index 9e2e67b5623..b10a2d80b6b 100644 --- a/frontend/src/toolbar/utils.ts +++ b/frontend/src/toolbar/utils.ts @@ -2,8 +2,9 @@ import { finder } from '@medv/finder' import { CLICK_TARGET_SELECTOR, CLICK_TARGETS, escapeRegex, TAGS_TO_IGNORE } from 'lib/actionUtils' import { cssEscape } from 'lib/utils/cssEscape' import { querySelectorAllDeep } from 'query-selector-shadow-dom' +import { CSSProperties } from 'react' -import { ActionStepForm, BoxColor, ElementRect } from '~/toolbar/types' +import { ActionStepForm, ElementRect } from '~/toolbar/types' import { ActionStepType, StringMatching } from '~/types' export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__' @@ -246,26 +247,21 @@ export function getElementForStep(step: ActionStepForm, allElements?: HTMLElemen return null } -export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): BoxColor | undefined { +export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): CSSProperties | undefined { if (color === 'blue') { return { backgroundBlendMode: 'multiply', background: `hsla(240, 90%, 58%, ${opacity})`, - boxShadow: `hsla(240, 90%, 27%, 0.5) 0px 3px 10px ${hover ? 4 : 2}px`, + boxShadow: `hsla(240, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 4 : 0}px`, + outline: `hsla(240, 90%, 58%, 0.5) solid 1px`, } } if (color === 'red') { return { backgroundBlendMode: 'multiply', background: `hsla(4, 90%, 58%, ${opacity})`, - boxShadow: `hsla(4, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`, - } - } - if (color === 'green') { - return { - backgroundBlendMode: 'multiply', - background: `hsla(97, 90%, 58%, ${opacity})`, - boxShadow: `hsla(97, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`, + boxShadow: `hsla(4, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 5 : 0}px`, + outline: `hsla(4, 90%, 58%, 0.5) solid 1px`, } } } diff --git a/package.json b/package.json index e08bbf6ade6..4a262124167 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "fflate": "^0.7.4", "fs-extra": "^10.0.0", "fuse.js": "^6.6.2", + "heatmap.js": "^2.0.5", "husky": "^7.0.4", "image-blob-reduce": "^4.1.0", "kea": "^3.1.5", @@ -146,7 +147,7 @@ "postcss": "^8.4.31", "postcss-preset-env": "^9.3.0", "posthog-js": "1.128.3", - "posthog-js-lite": "2.5.0", + "posthog-js-lite": "3.0.0", "prettier": "^2.8.8", "prop-types": "^15.7.2", "protomaps-themes-base": "2.0.0-alpha.1", @@ -217,6 +218,7 @@ "@types/d3": "^7.4.0", "@types/d3-sankey": "^0.12.1", "@types/dompurify": "^3.0.3", + "@types/heatmap.js": "^2.0.41", "@types/image-blob-reduce": "^4.1.1", "@types/jest": "^29.2.3", "@types/jest-image-snapshot": "^6.1.0", @@ -307,7 +309,8 @@ "playwright": "1.41.2" }, "patchedDependencies": { - "rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch" + "rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch", + "heatmap.js@2.0.5": "patches/heatmap.js@2.0.5.patch" } }, "lint-staged": { diff --git a/patches/heatmap.js@2.0.5.patch b/patches/heatmap.js@2.0.5.patch new file mode 100644 index 00000000000..2ad80898654 --- /dev/null +++ b/patches/heatmap.js@2.0.5.patch @@ -0,0 +1,13 @@ +diff --git a/build/heatmap.js b/build/heatmap.js +index 3eee39ea8c127b065fb4df763ab76af152e7d368..a37c950b937d04805b62832c661890931d0f3ff1 100644 +--- a/build/heatmap.js ++++ b/build/heatmap.js +@@ -524,7 +524,7 @@ var Canvas2dRenderer = (function Canvas2dRendererClosure() { + + } + +- img.data = imgData; ++ //img.data = imgData; + this.ctx.putImageData(img, x, y); + + this._renderBoundaries = [1000, 1000, 0, 0]; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8497ec648a8..cdb85637f8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true @@ -8,6 +8,9 @@ overrides: playwright: 1.41.2 patchedDependencies: + heatmap.js@2.0.5: + hash: gydrxrztd4ruyhouu6tu7zh43e + path: patches/heatmap.js@2.0.5.patch rrweb@2.0.0-alpha.12: hash: t3xxecww6aodjl4qopwv6jdxmq path: patches/rrweb@2.0.0-alpha.12.patch @@ -196,6 +199,9 @@ dependencies: fuse.js: specifier: ^6.6.2 version: 6.6.2 + heatmap.js: + specifier: ^2.0.5 + version: 2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e) husky: specifier: ^7.0.4 version: 7.0.4 @@ -257,8 +263,8 @@ dependencies: specifier: 1.128.3 version: 1.128.3 posthog-js-lite: - specifier: 2.5.0 - version: 2.5.0 + specifier: 3.0.0 + version: 3.0.0 prettier: specifier: ^2.8.8 version: 2.8.8 @@ -350,7 +356,7 @@ dependencies: optionalDependencies: fsevents: specifier: ^2.3.2 - version: 2.3.3 + version: 2.3.2 devDependencies: '@babel/core': @@ -467,6 +473,9 @@ devDependencies: '@types/dompurify': specifier: ^3.0.3 version: 3.0.3 + '@types/heatmap.js': + specifier: ^2.0.41 + version: 2.0.41 '@types/image-blob-reduce': specifier: ^4.1.1 version: 4.1.1 @@ -7734,7 +7743,7 @@ packages: resolution: {integrity: sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==} dependencies: '@types/d3-array': 3.0.3 - '@types/geojson': 7946.0.10 + '@types/geojson': 7946.0.12 dev: true /@types/d3-delaunay@6.0.1: @@ -7776,7 +7785,7 @@ packages: /@types/d3-geo@3.0.2: resolution: {integrity: sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==} dependencies: - '@types/geojson': 7946.0.10 + '@types/geojson': 7946.0.12 dev: true /@types/d3-hierarchy@3.1.0: @@ -7985,13 +7994,8 @@ packages: resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} dev: true - /@types/geojson@7946.0.10: - resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==} - dev: true - /@types/geojson@7946.0.12: resolution: {integrity: sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==} - dev: false /@types/google.maps@3.55.4: resolution: {integrity: sha512-Ip3IfRs3RZjeC88V8FGnWQTQXeS5gkJedPSosN6DMi9Xs8buGTpsPq6UhREoZsGH+62VoQ6jiRBUR8R77If69w==} @@ -8009,6 +8013,12 @@ packages: '@types/unist': 2.0.6 dev: false + /@types/heatmap.js@2.0.41: + resolution: {integrity: sha512-3oHffxC+N+1EKXjeC65klk1kHnLJ5i6tEKFNb/04J+qSfQuCliacsNBWDpt59JfG2vBXRRn+ICbzRZj48j6HfQ==} + dependencies: + '@types/leaflet': 0.7.40 + dev: true + /@types/hogan.js@3.0.5: resolution: {integrity: sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==} dev: false @@ -8078,6 +8088,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/leaflet@0.7.40: + resolution: {integrity: sha512-R2UwXOKwnKZi9zNm37WbPTAVuqHmysE6NVihkc5DUrovTirUxFSbZzvXrlwv0n5sibe0w8VF1bWu0ta4kZlAaA==} + dependencies: + '@types/geojson': 7946.0.12 + dev: true + /@types/less@3.0.6: resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==} dev: false @@ -12825,7 +12841,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /fsevents@2.3.3: @@ -13273,6 +13288,11 @@ packages: resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==} dev: true + /heatmap.js@2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e): + resolution: {integrity: sha512-CG2gYFP5Cv9IQCXEg3ZRxnJDyAilhWnQlAuHYGuWVzv6mFtQelS1bR9iN80IyDmFECbFPbg6I0LR5uAFHgCthw==} + dev: false + patched: true + /helpertypes@0.0.19: resolution: {integrity: sha512-J00e55zffgi3yVnUp0UdbMztNkr2PnizEkOe9URNohnrNhW5X0QpegkuLpOmFQInpi93Nb8MCjQRHAiCDF42NQ==} engines: {node: '>=10.0.0'} @@ -17453,8 +17473,8 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 - /posthog-js-lite@2.5.0: - resolution: {integrity: sha512-Urvlp0Vu9h3td0BVFWt0QXFJDoOZcaAD83XM9d91NKMKTVPZtfU0ysoxstIf5mw/ce9ZfuMgpWPaagrZI4rmSg==} + /posthog-js-lite@3.0.0: + resolution: {integrity: sha512-dyajjnfzZD1tht4N7p7iwf7nBnR1MjVaVu+MKr+7gBgA39bn28wizCIJZztZPtHy4PY0YwtSGgwfBCuG/hnHgA==} dev: false /posthog-js@1.128.3: