0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 00:47:50 +01:00

feat: Heatmaps toolbar code (#21630)

This commit is contained in:
Ben White 2024-04-23 09:52:44 +02:00 committed by GitHub
parent 2f6344ab77
commit c0b34067de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1106 additions and 272 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -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]

View File

@ -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)

View File

@ -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"
>
<ToolbarMenu.Header>
<ToolbarMenu.Header className="border-b">
<h1 className="p-1 font-bold text-sm mb-0">
{selectedActionId === 'new' ? 'New ' : 'Edit '}
action
@ -124,9 +124,12 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
icon={<IconPencil />}
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 => {
</ToolbarMenu.Body>
<ToolbarMenu.Footer>
<span className="flex-1">
<LemonButton
type="secondary"
size="small"
onClick={() => selectAction(null)}
sideIcon={<IconX />}
>
Cancel
</LemonButton>
{selectedActionId !== 'new' ? (
<LemonButton
type="secondary"
status="danger"
onClick={deleteAction}
icon={<IconTrash />}
size="small"
>
Delete
</LemonButton>
) : null}
</span>
<LemonButton type="primary" htmlType="submit">
<LemonButton type="secondary" size="small" onClick={() => selectAction(null)}>
Cancel
</LemonButton>
<LemonButton type="primary" htmlType="submit" size="small">
{selectedActionId === 'new' ? 'Create ' : 'Save '}
action
</LemonButton>
{selectedActionId !== 'new' ? (
<LemonButton type="secondary" status="danger" onClick={deleteAction} icon={<IconTrash />} />
) : null}
</ToolbarMenu.Footer>
</Form>
</ToolbarMenu>

View File

@ -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"
>
<span className="min-w-[2rem] inline-block text-left">{index + 1}.</span>
<span className="flex-grow">

View File

@ -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<actionsTabLogicType>([
}
},
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

View File

@ -1,15 +1,24 @@
export function ToolbarMenu({ children }: { children: React.ReactNode }): JSX.Element {
return <div className="w-full h-full flex flex-col overflow-hidden">{children}</div>
import clsx from 'clsx'
export type ToolbarMenuProps = {
children: React.ReactNode
className?: string
}
ToolbarMenu.Header = function ToolbarMenuHeader({ children }: { children: React.ReactNode }): JSX.Element {
return <div className="pt-1 px-1">{children}</div>
export function ToolbarMenu({ children, className }: ToolbarMenuProps): JSX.Element {
return <div className={clsx('w-full h-full flex flex-col overflow-hidden', className)}>{children}</div>
}
ToolbarMenu.Body = function ToolbarMenuBody({ children }: { children: React.ReactNode }): JSX.Element {
return <div className="flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20">{children}</div>
ToolbarMenu.Header = function ToolbarMenuHeader({ children, className }: ToolbarMenuProps): JSX.Element {
return <div className={clsx('pt-1 px-1', className)}>{children}</div>
}
ToolbarMenu.Footer = function ToolbarMenufooter({ children }: { children: React.ReactNode }): JSX.Element {
return <div className="flex flex-row items-center p-2 border-t">{children}</div>
ToolbarMenu.Body = function ToolbarMenuBody({ children, className }: ToolbarMenuProps): JSX.Element {
return (
<div className={clsx('flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20', className)}>{children}</div>
)
}
ToolbarMenu.Footer = function ToolbarMenufooter({ children, className }: ToolbarMenuProps): JSX.Element {
return <div className={clsx('flex flex-row items-center p-2 border-t gap-2', className)}>{children}</div>
}

View File

@ -1,6 +1,6 @@
import { ElementRect } from '~/toolbar/types'
interface HeatmapElementProps {
interface AutocaptureElementProps {
rect?: ElementRect
style: Record<string, any>
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
}

View File

@ -12,18 +12,18 @@ const heatmapLabelStyle = {
fontFamily: 'monospace',
}
interface HeatmapLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
interface AutocaptureElementLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
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
}

View File

@ -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 {
<IconX />
</div>
) : null}
{/* eslint-disable-next-line react/forbid-dom-props */}
<div style={{ minHeight, maxHeight, overflow: 'auto' }}>
<div
// eslint-disable-next-line react/forbid-dom-props
style={{
minHeight,
maxHeight,
overflow: 'auto',
boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`,
borderRadius: '8px',
}}
>
<ElementInfo />
</div>
</div>

View File

@ -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,
}}
>
<ScrollDepth />
<Heatmap />
{highlightElementMeta?.rect ? <FocusRect rect={highlightElementMeta.rect} /> : null}
{elementsToDisplay.map(({ rect, element }, index) => (
<HeatmapElement
<AutocaptureElement
key={`inspect-${index}`}
rect={rect}
style={{
pointerEvents: heatmapPointerEvents,
cursor: 'pointer',
zIndex: 0,
zIndex: hoverElement === element ? 2 : 1,
opacity:
(!hoverElement && !selectedElement) ||
selectedElement === element ||
@ -65,6 +70,7 @@ export function Elements(): JSX.Element {
? 1
: 0.4,
transition: 'opacity 0.2s, box-shadow 0.2s',
borderRadius: 5,
...getBoxColors('blue', hoverElement === element || selectedElement === element),
}}
onClick={() => selectElement(element)}
@ -76,14 +82,15 @@ export function Elements(): JSX.Element {
{heatmapElements.map(({ rect, count, clickCount, rageclickCount, element }, index) => {
return (
<Fragment key={`heatmap-${index}`}>
<HeatmapElement
<AutocaptureElement
rect={rect}
style={{
pointerEvents: inspectEnabled ? 'none' : heatmapPointerEvents,
zIndex: 1,
zIndex: hoverElement === element ? 4 : 3,
opacity: !hoverElement || hoverElement === element ? 1 : 0.4,
transition: 'opacity 0.2s, box-shadow 0.2s',
cursor: 'pointer',
borderRadius: 5,
...getBoxColors(
'red',
hoverElement === element,
@ -95,7 +102,7 @@ export function Elements(): JSX.Element {
onMouseOut={() => selectedElement === null && setHoverElement(null)}
/>
{!!clickCount && (
<HeatmapLabel
<AutocaptureElementLabel
rect={rect}
style={{
pointerEvents: heatmapPointerEvents,
@ -122,10 +129,10 @@ export function Elements(): JSX.Element {
onMouseOut={() => selectedElement === null && setHoverElement(null)}
>
{compactNumber(clickCount || 0)}
</HeatmapLabel>
</AutocaptureElementLabel>
)}
{!!rageclickCount && (
<HeatmapLabel
<AutocaptureElementLabel
rect={rect}
style={{
pointerEvents: heatmapPointerEvents,
@ -153,7 +160,7 @@ export function Elements(): JSX.Element {
onMouseOut={() => selectedElement === null && setHoverElement(null)}
>
{compactNumber(rageclickCount)}&#128545;
</HeatmapLabel>
</AutocaptureElementLabel>
)}
</Fragment>
)
@ -162,7 +169,7 @@ export function Elements(): JSX.Element {
{labelsToDisplay.map(({ element, rect, index }, loopIndex) => {
if (rect) {
return (
<HeatmapLabel
<AutocaptureElementLabel
key={`label-${loopIndex}`}
rect={rect}
align="left"
@ -182,7 +189,7 @@ export function Elements(): JSX.Element {
onMouseOut={() => selectedElement === null && setHoverElement(null)}
>
{(index || loopIndex) + 1}
</HeatmapLabel>
</AutocaptureElementLabel>
)
}
})}

View File

@ -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<HeatmapJS<'value', 'x', 'y'>>()
const heatmapsJsContainerRef = useRef<HTMLDivElement | null>()
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 (
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0" ref={setHeatmapContainer} />
</div>
)
}

View File

@ -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<null | number>(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 (
<div
className="absolute left-0 right-0 flex items-center z-10"
// eslint-disable-next-line react/forbid-dom-props
style={{
top: mouseY,
transform: 'translateY(-50%)',
}}
>
<div className="border-b w-full" />
<div className="bg-border whitespace-nowrap text-default rounded p-2 font-semibold">
{rawHeatmapLoading ? (
<>Loading...</>
) : heatmapElements.length ? (
<>{percentage.toPrecision(4)}% scrolled this far</>
) : (
<>No scroll data for the current dimension range</>
)}
</div>
<div className="border-b w-10" />
</div>
)
}
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 (
<div className="fixed inset-0 overflow-hidden">
<div
className="absolute top-0 left-0 right-0"
// eslint-disable-next-line react/forbid-dom-props
style={{
transform: `translateY(${-scrollOffset}px)`,
}}
>
{heatmapElements.map(({ y, count }, i) => (
<div
key={y}
// eslint-disable-next-line react/forbid-dom-props
style={{
position: 'absolute',
top: heatmapElements[i - 1]?.y ?? 0,
left: 0,
width: '100%',
height: y - (heatmapElements[i - 1]?.y ?? 0),
backgroundColor: color(count),
opacity: 0.5,
}}
/>
))}
</div>
<ScrollDepthMouseInfo />
</div>
)
}

View File

@ -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<elementsLogicType>([
}),
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<elementsLogicType>([
}
}
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,

View File

@ -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<ElementsEventType> = {
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<heatmapLogicType>([
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<heatmapLogicType>([
}),
enableHeatmap: true,
disableHeatmap: true,
setShowHeatmapTooltip: (showHeatmapTooltip: boolean) => ({ showHeatmapTooltip }),
setShiftPressed: (shiftPressed: boolean) => ({ shiftPressed }),
setHeatmapFilter: (filter: Partial<FilterType>) => ({ filter }),
setCommonFilters: (filters: CommonFilters) => ({ filters }),
setHeatmapFilters: (filters: HeatmapFilters) => ({ filters }),
patchHeatmapFilters: (filters: Partial<HeatmapFilters>) => ({ 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<heatmapLogicType>([
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<FilterType>,
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<heatmapLogicType>([
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<heatmapLogicType>([
},
},
],
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<FilterType>) => {
return dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')
(s) => [s.commonFilters],
(commonFilters: Partial<FilterType>) => {
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<heatmapLogicType>([
},
],
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<HTMLElement, CountedHTMLElement>()
;(elements || []).forEach((countedElement) => {
const trimmedElement = trimElement(countedElement.element)
@ -273,22 +382,225 @@ export const heatmapLogic = kea<heatmapLogicType>([
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<heatmapLogicType>([
}
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()
},
})),
])

View File

@ -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<flagsToolbarLogicType>([
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<flagsToolbarLogicType>([
} else {
clientPostHog.featureFlags.override(false)
}
posthogJS.capture('toolbar feature flag override removed')
toolbarPosthogJS.capture('toolbar feature flag override removed')
actions.checkLocalOverrides()
clientPostHog.featureFlags.reloadFeatureFlags()
}

View File

@ -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()
}

View File

@ -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 (
<p className="my-2 bg-danger-highlight border border-danger rounded p-2">
{scrollDepthPosthogJsError === 'version' ? (
<>This feature requires a newer version of posthog-js</>
) : scrollDepthPosthogJsError === 'disabled' ? (
<>
Your posthog-js config has <i>disable_scroll_properties</i> set - these properties are required for
scroll depth calculations to work.
</>
) : null}
</p>
)
}
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 (
<ToolbarMenu>
<ToolbarMenu.Header>
<LemonInput value={wildcardHref} onChange={setWildcardHref} />
<div className="space-y-1 border-b px-1 pb-2">
<div className="text-muted p-1">Use * as a wildcard</div>
<div className="flex flex-row items-center space-x-2">
<LemonMenu items={dateItems}>
<LemonButton size="small" type="secondary">
{dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')}
</LemonButton>
</LemonMenu>
<div className="flex gap-1">
<LemonInput className="flex-1" value={wildcardHref} onChange={setWildcardHref} />
<LemonButton
type="secondary"
icon={<IconMagicWand />}
size="small"
onClick={() => autoWildcardHref()}
tooltip={
<>
You can use the wildcard character <code>*</code> to match any character in the URL. For
example, <code>https://example.com/*</code> will match{' '}
<code>https://example.com/page</code> and <code>https://example.com/page/1</code>.
<br />
Click this button to automatically wildcards where we believe it would make sense
</>
}
/>
</div>
<LemonButton
icon={<IconSync />}
type="secondary"
size="small"
onClick={loadMoreElementStats}
disabledReason={
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
}
>
Load more
<div className="flex flex-row items-center gap-2 py-2 border-b">
<LemonMenu items={dateItems}>
<LemonButton size="small" type="secondary">
{dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')}
</LemonButton>
{heatmapLoading ? <Spinner /> : null}
</div>
<div>
Found: {countedElements.length} elements / {clickCount} clicks!
</div>
<Tooltip title="Matching links by their target URL can exclude clicks from the heatmap if the URL is too unique.">
<LemonSwitch
checked={matchLinksByHref}
label="Match links by their target URL"
onChange={(checked) => setMatchLinksByHref(checked)}
fullWidth={true}
bordered={true}
/>
</Tooltip>
</LemonMenu>
</div>
</ToolbarMenu.Header>
<ToolbarMenu.Body>
<div className="flex flex-col space-y-2">
<div className="flex flex-col w-full h-full">
{heatmapLoading ? (
<span className="flex-1 flex justify-center items-center p-4">
<Spinner className="text-2xl" />
</span>
) : countedElements.length ? (
countedElements.map(({ element, count, actionStep }, index) => {
return (
<div
className="p-2 flex flex-row justify-between cursor-pointer hover:bg-primary-highlight"
key={index}
onClick={() => setSelectedElement(element)}
onMouseEnter={() => setHighlightElement(element)}
onMouseLeave={() => setHighlightElement(null)}
>
<div>
{index + 1}.&nbsp;
{actionStep?.text ||
(actionStep?.tag_name ? (
<code>&lt;{actionStep.tag_name}&gt;</code>
) : (
<em>Element</em>
))}
</div>
<div>{count} clicks</div>
{showNewHeatmaps ? (
<div className="border-b p-2">
<LemonSwitch
className="w-full"
checked={!!heatmapFilters.enabled}
label={<>Heatmaps {rawHeatmapLoading ? <Spinner /> : null}</>}
onChange={(e) =>
patchHeatmapFilters({
enabled: e,
})
}
/>
{heatmapFilters.enabled && (
<>
<p>
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.
</p>
<div className="space-y-2">
<LemonLabel>Heatmap type</LemonLabel>
<div className="flex gap-2 justify-between items-center">
<LemonSegmentedButton
onChange={(e) => 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"
/>
</div>
)
})
) : (
<div className="p-2">No elements found.</div>
{heatmapFilters.type === 'scrolldepth' && (
<>
<p>
Scroll depth uses additional information from Pageview and Pageleave
events to indicate how far down the page users have scrolled.
</p>
<ScrollDepthJSWarning />
</>
)}
<LemonLabel>Aggregation</LemonLabel>
<div className="flex gap-2 justify-between items-center">
<LemonSegmentedButton
onChange={(e) => patchHeatmapFilters({ aggregation: e })}
value={heatmapFilters.aggregation ?? 'total_count'}
options={[
{
value: 'total_count',
label: 'Total count',
},
{
value: 'unique_visitors',
label: 'Unique visitors',
},
]}
size="small"
/>
</div>
<LemonLabel>Viewport accuracy</LemonLabel>
<div className="flex gap-2 justify-between items-center">
<LemonSlider
className="flex-1"
min={0}
max={1}
step={0.01}
value={heatmapFilters.viewportAccuracy ?? 0}
onChange={(value) => patchHeatmapFilters({ viewportAccuracy: value })}
/>
<Tooltip
title={`
The range of values
Heatmap will be loaded for all viewports where the width is above
`}
>
<code className="w-[12rem] text-right text-xs whitsepace-nowrap">
{`${Math.round((heatmapFilters.viewportAccuracy ?? 1) * 100)}% (${
viewportRange.min
}px - ${viewportRange.max}px)`}
</code>
</Tooltip>
</div>
{heatmapFilters.type !== 'scrolldepth' ? (
<>
<LemonLabel>Fixed positioning calculation</LemonLabel>
<p>
PostHog JS will attempt to detect fixed elements such as headers or
modals and will therefore show those heatmap areas, ignoring the scroll
value.
<br />
You can choose to show these areas as fixed, include them with scrolled
data or hide them altogether.
</p>
<LemonSegmentedButton
onChange={setHeatmapFixedPositionMode}
value={heatmapFixedPositionMode}
options={[
{
value: 'fixed',
label: 'Show fixed',
},
{
value: 'relative',
label: 'Show scrolled',
},
{
value: 'hidden',
label: 'Hide',
},
]}
size="small"
/>
</>
) : null}
</div>
</>
)}
</div>
) : null}
<div className="p-2">
{showNewHeatmaps ? (
<LemonSwitch
className="w-full"
checked={!!clickmapsEnabled}
label={<>Clickmaps (autocapture) {elementStatsLoading ? <Spinner /> : null}</>}
onChange={(e) => toggleClickmapsEnabled(e)}
/>
) : null}
{(clickmapsEnabled || !showNewHeatmaps) && (
<>
{showNewHeatmaps ? (
<p>
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.
</p>
) : null}
<div className="flex items-center gap-2">
<LemonButton
icon={<IconSync />}
type="secondary"
size="small"
onClick={loadMoreElementStats}
disabledReason={
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
}
>
Load more
</LemonButton>
<Tooltip
title={
<span>
Matching links by their target URL can exclude clicks from the heatmap if
the URL is too unique.
</span>
}
>
<LemonSwitch
className="flex-1"
checked={matchLinksByHref}
label="Match links by their target URL"
onChange={(checked) => setMatchLinksByHref(checked)}
fullWidth={true}
bordered={true}
/>
</Tooltip>
</div>
<div className="my-2">
Found: {countedElements.length} elements / {clickCount} clicks!
</div>
<div className="flex flex-col w-full h-full">
{countedElements.length ? (
countedElements.map(({ element, count, actionStep }, index) => {
return (
<LemonButton
key={index}
size="small"
fullWidth
onClick={() => setSelectedElement(element)}
>
<div
className="flex flex-1 justify-between"
key={index}
onMouseEnter={() => setHighlightElement(element)}
onMouseLeave={() => setHighlightElement(null)}
>
<div>
{index + 1}.&nbsp;
{actionStep?.text ||
(actionStep?.tag_name ? (
<code>&lt;{actionStep.tag_name}&gt;</code>
) : (
<em>Element</em>
))}
</div>
<div>{count} clicks</div>
</div>
</LemonButton>
)
})
) : (
<div className="p-2">No elements found.</div>
)}
</div>
</>
)}
</div>
</ToolbarMenu.Body>
</ToolbarMenu>

View File

@ -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<currentPageLogicType>([
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<currentPageLogicType>([
],
})),
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) {

View File

@ -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<toolbarConfigLogicType>([
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<toolbarConfigLogicType>([
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 })
}),
])

View File

@ -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<boolean | string | undefined>(toolbarPosthogJS.getFeatureFlag(flag))
useEffect(() => {
return toolbarPosthogJS.onFeatureFlag(flag, (value) => setFlagValue(value))
}, [flag, match])
if (match) {
return flagValue === match
}
return !!flagValue
}

View File

@ -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<ActionType, 'id' | 'created_at' | 'created_by'>
export interface ActionStepForm extends ActionStepType {

View File

@ -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`,
}
}
}

View File

@ -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": {

View File

@ -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];

View File

@ -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: