feat(heatmaps): new heatmaps (#39825)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Alex V
2025-10-31 16:27:40 +01:00
committed by GitHub
parent d0d6e0dcf4
commit 944fc3b320
66 changed files with 2424 additions and 126 deletions

2
.gitignore vendored
View File

@@ -122,3 +122,5 @@ bin/mprocs*.local.yaml
# Claude Code review outputs
CODE_REVIEW.md
CODE_DEBUGGING_SESSION.md
data/seaweedfs

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -108,6 +108,10 @@ import {
GoogleAdsConversionActionType,
Group,
GroupListParams,
HeatmapScreenshotContentResponse,
HeatmapScreenshotType,
HeatmapStatus,
HeatmapType,
HogFunctionIconResponse,
HogFunctionStatus,
HogFunctionTemplateType,
@@ -1572,6 +1576,24 @@ export class ApiRequest {
public sessionSummary(teamId?: TeamType['id']): ApiRequest {
return this.environmentsDetail(teamId).addPathComponent('session_summaries')
}
// Heatmap screenshots
public heatmapScreenshots(teamId?: TeamType['id']): ApiRequest {
return this.environmentsDetail(teamId).addPathComponent('heatmap_screenshots')
}
public heatmapScreenshot(id: number, teamId?: TeamType['id']): ApiRequest {
return this.heatmapScreenshots(teamId).addPathComponent(id)
}
public heatmapScreenshotsSaved(teamId?: TeamType['id']): ApiRequest {
// Deprecated path: kept for potential fallback during rollout
return this.environmentsDetail(teamId).addPathComponent('saved')
}
public heatmapScreenshotSaved(id: number | string, teamId?: TeamType['id']): ApiRequest {
return this.heatmapScreenshotsSaved(teamId).addPathComponent(id)
}
}
const normalizeUrl = (url: string): string => {
@@ -4540,6 +4562,64 @@ const api = {
return results
},
heatmapScreenshots: {
async getContent(id: number): Promise<HeatmapScreenshotContentResponse> {
const response = await new ApiRequest().heatmapScreenshot(id).withAction('content').getResponse()
if (
response.ok &&
(response.headers.get('content-type')?.includes('image/jpeg') ||
response.headers.get('content-type')?.includes('image/png'))
) {
// 200: JPEG/PNG image data
return { success: true, data: response }
}
// 202/404/501: JSON with screenshot metadata
const jsonData = await response.json()
return { success: false, data: jsonData }
},
},
savedHeatmaps: {
async list(
params: {
type?: HeatmapType
status?: HeatmapStatus
search?: string
limit?: number
offset?: number
} = {}
): Promise<CountedPaginatedResponse<HeatmapScreenshotType>> {
return await new ApiRequest().heatmapScreenshotsSaved().withQueryString(params).get()
},
async create(data: {
name: string
url: string
data_url?: string | null
width?: number
type?: HeatmapType
}): Promise<HeatmapScreenshotType> {
return await new ApiRequest().heatmapScreenshotsSaved().create({ data })
},
async get(id: number | string): Promise<HeatmapScreenshotType> {
return await new ApiRequest().heatmapScreenshotSaved(id).get()
},
async update(
id: number | string,
data: Partial<{
url: string
data_url: string | null
width: number
type: HeatmapType
}>
): Promise<HeatmapScreenshotType> {
return await new ApiRequest().heatmapScreenshotSaved(id).update({ data })
},
},
sessionSummaries: {
async create(data: { session_ids: string[]; focus_area?: string }): Promise<SessionSummaryResponse> {
return await new ApiRequest().sessionSummary().withAction('create_session_summaries').create({ data })

View File

@@ -145,6 +145,8 @@ export const describerFor = (logItem?: ActivityLogItem): Describer | undefined =
return dataWarehouseSavedQueryActivityDescriber
case ActivityScope.REPLAY:
return replayActivityDescriber
case ActivityScope.HEATMAP:
return (logActivity, asNotification) => defaultDescriber(logActivity, asNotification)
case ActivityScope.EXPERIMENT:
return experimentActivityDescriber
case ActivityScope.TAG:

View File

@@ -95,7 +95,7 @@ function ScrollDepthMouseInfo({
}
// Get the iframe's offset from the top of the viewport
const iframe = document.getElementById('heatmap-iframe')
const iframe = document.getElementById('heatmap-iframe') || document.getElementById('heatmap-screenshot')
if (!iframe) {
return null
}

View File

@@ -43,7 +43,10 @@ export const appScenes: Record<Scene | string, () => any> = {
[Scene.Group]: () => import('./groups/Group'),
[Scene.GroupsNew]: () => import('./groups/GroupsNew'),
[Scene.Groups]: () => import('./groups/Groups'),
[Scene.Heatmaps]: () => import('./heatmaps/HeatmapsScene'),
[Scene.Heatmaps]: () => import('./heatmaps/scenes/heatmaps/HeatmapsScene'),
[Scene.HeatmapNew]: () => import('./heatmaps/scenes/heatmap/HeatmapNewScene'),
[Scene.HeatmapRecording]: () => import('./heatmaps/scenes/heatmap/HeatmapRecordingScene'),
[Scene.Heatmap]: () => import('./heatmaps/scenes/heatmap/HeatmapScene'),
[Scene.HogFunction]: () => import('./hog-functions/HogFunctionScene'),
[Scene.Insight]: () => import('./insights/InsightScene'),
[Scene.IntegrationsRedirect]: () => import('./IntegrationsRedirect/IntegrationsRedirect'),

View File

@@ -1,84 +0,0 @@
import { Meta, StoryObj } from '@storybook/react'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'
import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
import { MockSignature } from '~/mocks/utils'
import heatmapResults from './__mocks__/heatmapResults.json'
const query = (topUrls: [string, number][] = []): MockSignature => {
return async (req, res, ctx) => {
const json = await req.clone().json()
const qry = json.query.query
// top urls query
if (qry?.includes('SELECT properties.$current_url AS url, count()')) {
return res(
ctx.json({
results: topUrls,
})
)
}
return res(
ctx.json({
results: [],
})
)
}
}
const meta: Meta = {
component: App,
title: 'Scenes-App/Heatmaps',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
mockDate: '2023-01-28', // To stabilize relative dates
pageUrl: urls.heatmaps(),
testOptions: {
waitForLoadersToDisappear: true,
},
},
decorators: [
mswDecorator({
get: {
'/api/projects/:team_id/integrations': {},
'/api/heatmap': heatmapResults,
},
post: {
'/api/environments/:team_id/query': query(),
},
}),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const HeatmapsBrowserNoPagesAvailable: Story = {}
export function HeatmapsBrowserNoPageSelected(): JSX.Element {
useStorybookMocks({
post: {
'/api/environments/:team_id/query': query([
['https://posthog.com/most-views', 100],
['https://posthog.com/fewest-views', 50],
]),
},
})
return <App />
}
export const HeatmapsBrowserWithPageSelected: Story = {
parameters: {
pageUrl: urls.heatmaps('pageURL=https://example.com&heatmapPalette=red&heatmapFilters={"type"%3A"mousemove"}'),
},
}
export const HeatmapsBrowserWithUnauthorizedPageSelected: Story = {
parameters: {
pageUrl: urls.heatmaps('pageURL=https://random.example.com'),
},
}

View File

@@ -11,10 +11,9 @@ import { heatmapDataLogic } from 'lib/components/heatmaps/heatmapDataLogic'
import { LoadingBar } from 'lib/lemon-ui/LoadingBar'
import { Popover } from 'lib/lemon-ui/Popover'
import { inStorybook, inStorybookTestRunner } from 'lib/utils'
import { heatmapLogic } from 'scenes/heatmaps/scenes/heatmap/heatmapLogic'
import { TestAccountFilter } from 'scenes/insights/filters/TestAccountFilter'
import { heatmapsBrowserLogic } from './heatmapsBrowserLogic'
const useDebounceLoading = (loading: boolean, delay = 200): boolean => {
const [debouncedLoading, setDebouncedLoading] = useState(false)
@@ -30,10 +29,8 @@ const useDebounceLoading = (loading: boolean, delay = 200): boolean => {
}
export function ViewportChooser(): JSX.Element {
const logic = heatmapsBrowserLogic()
const { widthOverride } = useValues(logic)
const { setIframeWidth } = useActions(logic)
const { widthOverride } = useValues(heatmapLogic)
const { setIframeWidth } = useActions(heatmapLogic)
const options = [
{

View File

@@ -4,7 +4,7 @@ import React, { useEffect } from 'react'
import { HeatmapCanvas } from 'lib/components/heatmaps/HeatmapCanvas'
import { heatmapDataLogic } from 'lib/components/heatmaps/heatmapDataLogic'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
import { heatmapsBrowserLogic } from 'scenes/heatmaps/heatmapsBrowserLogic'
import { heatmapsBrowserLogic } from 'scenes/heatmaps/components/heatmapsBrowserLogic'
export function FixedReplayHeatmapBrowser({
iframeRef,
@@ -37,6 +37,7 @@ export function FixedReplayHeatmapBrowser({
>
<HeatmapCanvas positioning="absolute" widthOverride={widthOverride} context="in-app" />
<iframe
id="heatmap-iframe"
ref={iframeRef}
className="w-full h-full bg-white"
srcDoc={replayIframeData?.html}

View File

@@ -0,0 +1,46 @@
import { useActions, useValues } from 'kea'
import { LemonBanner, LemonInput, LemonLabel } from '@posthog/lemon-ui'
import { HeatmapsForbiddenURL } from 'scenes/heatmaps/components/HeatmapsForbiddenURL'
import { heatmapLogic } from 'scenes/heatmaps/scenes/heatmap/heatmapLogic'
export function HeatmapHeader(): JSX.Element {
const { dataUrl, displayUrl, isBrowserUrlAuthorized, screenshotError } = useValues(heatmapLogic)
const { setDataUrl } = useActions(heatmapLogic)
return (
<>
<div className="flex-none md:flex justify-between items-end gap-2 w-full">
<div className="flex gap-2 flex-1 min-w-0">
<div className="flex-1">
<div>
<LemonLabel>Heatmap data URL</LemonLabel>
<div className="flex gap-2 justify-between">
<LemonInput
size="small"
placeholder={displayUrl ? `Same as display URL: ${displayUrl}` : 'Enter a URL'}
value={dataUrl ?? ''}
onChange={(value) => {
setDataUrl(value || null)
}}
fullWidth={true}
/>
</div>
<div className="text-xs text-muted mt-1">
Add * for wildcards to aggregate data from multiple pages
</div>
</div>
{!isBrowserUrlAuthorized ? <HeatmapsForbiddenURL /> : null}
{/* Screenshot display section */}
{screenshotError && (
<div className="mt-2">
<LemonBanner type="error">{screenshotError}</LemonBanner>
</div>
)}
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,87 @@
import { BindLogic, useActions, useValues } from 'kea'
import { useRef } from 'react'
import { LemonBanner, LemonDivider, LemonInput, LemonLabel } from '@posthog/lemon-ui'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { FixedReplayHeatmapBrowser } from 'scenes/heatmaps/components/FixedReplayHeatmapBrowser'
import { HeatmapsWarnings } from 'scenes/heatmaps/components/HeatmapsWarnings'
import { urls } from 'scenes/urls'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { FilterPanel } from './FilterPanel'
import { heatmapsBrowserLogic } from './heatmapsBrowserLogic'
function UrlSearchHeader(): JSX.Element {
const logic = heatmapsBrowserLogic()
const { replayIframeData } = useValues(logic)
const { setReplayIframeDataURL } = useActions(logic)
return (
<>
<div className="flex-none md:flex justify-between items-end gap-2 w-full">
<div className="flex gap-2 flex-1 min-w-0">
<div className="flex-1">
<div className="mt-2">
<LemonLabel>Heatmap data URL</LemonLabel>
<div className="text-xs text-muted mb-1">
Add * for wildcards to aggregate data from multiple pages
</div>
<LemonInput
value={replayIframeData?.url}
onChange={(s) => setReplayIframeDataURL(s)}
className="truncate"
size="small"
/>
</div>
</div>
</div>
</div>
</>
)
}
export function HeatmapRecording(): JSX.Element {
const iframeRef = useRef<HTMLIFrameElement | null>(null)
const logicProps = { ref: iframeRef }
const logic = heatmapsBrowserLogic({ iframeRef })
const { hasValidReplayIframeData } = useValues(logic)
if (!hasValidReplayIframeData) {
return (
<LemonBanner
type="warning"
action={{
type: 'secondary',
icon: <IconOpenInNew />,
to: urls.replay(),
children: 'Open session recording',
}}
dismissKey="heatmaps-no-replay-iframe-data-warning"
>
This view is based on session recording data. Please open a session recording to view it.
</LemonBanner>
)
}
return (
<BindLogic logic={heatmapsBrowserLogic} props={logicProps}>
<SceneContent>
<HeatmapsWarnings />
<div className="overflow-hidden w-full min-h-screen">
<UrlSearchHeader />
<LemonDivider className="my-4" />
<FilterPanel />
<LemonDivider className="my-4" />
<div className="relative flex flex-1 overflow-hidden min-h-screen">
<FixedReplayHeatmapBrowser iframeRef={iframeRef} />
</div>
</div>
</SceneContent>
</BindLogic>
)
}

View File

@@ -20,7 +20,7 @@ import { DetectiveHog } from 'lib/components/hedgehogs'
import { dayjs } from 'lib/dayjs'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { FixedReplayHeatmapBrowser } from 'scenes/heatmaps/FixedReplayHeatmapBrowser'
import { FixedReplayHeatmapBrowser } from 'scenes/heatmaps/components/FixedReplayHeatmapBrowser'
import { teamLogic } from 'scenes/teamLogic'
import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic'

View File

@@ -0,0 +1,22 @@
import { useValues } from 'kea'
import { LemonBanner } from '@posthog/lemon-ui'
import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUrlList'
import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { heatmapLogic } from 'scenes/heatmaps/scenes/heatmap/heatmapLogic'
export function HeatmapsForbiddenURL(): JSX.Element {
const { dataUrl } = useValues(heatmapLogic)
return (
<div className="flex-1 gap-y-4 my-2">
<LemonBanner type="error">
{dataUrl} is not an authorized URL. Please add it to the list of authorized URLs to view heatmaps on
this page.
</LemonBanner>
<h4 className="my-4">Authorized Toolbar URLs</h4>
<AuthorizedUrlList type={AuthorizedUrlListType.TOOLBAR_URLS} />
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { useActions, useValues } from 'kea'
import { LemonBanner, LemonButton, LemonSkeleton } from '@posthog/lemon-ui'
import { DetectiveHog } from 'lib/components/hedgehogs'
import { heatmapLogic } from 'scenes/heatmaps/scenes/heatmap/heatmapLogic'
import { heatmapsBrowserLogic } from './heatmapsBrowserLogic'
export function HeatmapsInfoBanner(): JSX.Element {
return (
<div className="flex items-center flex-wrap gap-6">
<div className="w-50">
<DetectiveHog className="w-full h-full" />
</div>
<div className="flex-1">
<h2>Welcome to Heatmaps</h2>
<p>
Heatmaps are powered by the embedded JavaScript SDK and allow you to see a range of user
interactions directly on your website via the Toolbar.
</p>
<p>
You can also view heatmaps for any page on your website by entering the URL above. As long as the
page has the PostHog Toolbar installed, and can be loaded in an iframe, you can view heatmaps for
it.
</p>
</div>
</div>
)
}
export function HeatmapsUrlsList(): JSX.Element {
const { topUrls, topUrlsLoading, noPageviews } = useValues(heatmapsBrowserLogic)
const { setDisplayUrl } = useActions(heatmapLogic)
return (
<div className="flex-1 flex items-center overflow-y-auto">
<div className=" w-full">
<div className="gap-y-px p-2 border bg-surface-primary rounded">
{topUrlsLoading ? (
<LemonSkeleton className="h-10" repeat={10} />
) : noPageviews ? (
<LemonBanner type="info">
No pageview events have been received yet. Once you have some data, you'll see the most
viewed pages here.
</LemonBanner>
) : (
<>
<span className="text-sm font-medium text-muted ml-2">Most viewed pages:</span>
{topUrls?.map(({ url }) => (
<LemonButton
key={url}
fullWidth
onClick={() => {
setDisplayUrl(url)
}}
>
{url}
</LemonButton>
))}
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { LemonBanner } from '@posthog/lemon-ui'
export function HeatmapsInvalidURL(): JSX.Element {
return (
<div className="flex-1 py-4 gap-y-4 mb-2">
<LemonBanner type="error">Not a valid URL. Can't load a heatmap for that 😰</LemonBanner>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useActions, useValues } from 'kea'
import { IconGear } from '@posthog/icons'
import { LemonBanner } from '@posthog/lemon-ui'
import { teamLogic } from 'scenes/teamLogic'
import { sidePanelSettingsLogic } from '~/layout/navigation-3000/sidepanel/panels/sidePanelSettingsLogic'
export function HeatmapsWarnings(): JSX.Element | null {
const { currentTeam } = useValues(teamLogic)
const heatmapsEnabled = currentTeam?.heatmaps_opt_in
const { openSettingsPanel } = useActions(sidePanelSettingsLogic)
return !heatmapsEnabled ? (
<LemonBanner
type="warning"
action={{
type: 'secondary',
icon: <IconGear />,
onClick: () => openSettingsPanel({ sectionId: 'environment-autocapture', settingId: 'heatmaps' }),
children: 'Configure',
}}
dismissKey="heatmaps-might-be-disabled-warning"
>
You aren't collecting heatmaps data. Enable heatmaps in your project.
</LemonBanner>
) : null
}

View File

@@ -4,7 +4,7 @@ import React, { useEffect } from 'react'
import { HeatmapCanvas } from 'lib/components/heatmaps/HeatmapCanvas'
import { heatmapDataLogic } from 'lib/components/heatmaps/heatmapDataLogic'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
import { heatmapsBrowserLogic } from 'scenes/heatmaps/heatmapsBrowserLogic'
import { heatmapsBrowserLogic } from 'scenes/heatmaps/components/heatmapsBrowserLogic'
export function IframeHeatmapBrowser({
iframeRef,
@@ -28,8 +28,8 @@ export function IframeHeatmapBrowser({
}, [iframeWidth, setIframeWidth, widthOverride, setWindowWidthOverride])
return (
<div className="flex flex-row gap-x-2 w-full">
<div className="relative flex justify-center flex-1 w-full h-full overflow-scroll">
<div className="flex flex-row gap-x-2 w-full min-h-full">
<div className="relative flex justify-center flex-1 w-full min-h-full overflow-scroll">
<div
className="relative h-full overflow-scroll"
// eslint-disable-next-line react/forbid-dom-props

View File

@@ -0,0 +1,12 @@
{
"results": [
{ "pointer_target_fixed": true, "pointer_relative_x": 0.1, "pointer_y": 120, "count": 8 },
{ "pointer_target_fixed": true, "pointer_relative_x": 0.25, "pointer_y": 320, "count": 15 },
{ "pointer_target_fixed": false, "pointer_relative_x": 0.5, "pointer_y": 540, "count": 22 },
{ "pointer_target_fixed": false, "pointer_relative_x": 0.75, "pointer_y": 720, "count": 12 },
{ "pointer_target_fixed": true, "pointer_relative_x": 0.33, "pointer_y": 260, "count": 18 },
{ "pointer_target_fixed": false, "pointer_relative_x": 0.66, "pointer_y": 460, "count": 9 },
{ "pointer_target_fixed": true, "pointer_relative_x": 0.15, "pointer_y": 180, "count": 5 },
{ "pointer_target_fixed": false, "pointer_relative_x": 0.85, "pointer_y": 900, "count": 7 }
]
}

View File

@@ -42,9 +42,6 @@ export interface ReplayIframeData {
url: string | undefined
}
// team id is always available on window
const teamId = window.POSTHOG_APP_CONTEXT?.current_team?.id
// Helper function to detect if a URL contains regex pattern characters
const isUrlPattern = (url: string): boolean => {
return /[*+?^${}()|[\]\\]/.test(url)
@@ -58,7 +55,7 @@ const normalizeUrlPath = (urlObj: URL): string => {
}
export const heatmapsBrowserLogic = kea<heatmapsBrowserLogicType>([
path(['scenes', 'heatmaps', 'heatmapsBrowserLogic']),
path(['scenes', 'heatmaps', 'components', 'heatmapsBrowserLogic']),
props({} as HeatmapsBrowserLogicProps),
connect(() => ({
@@ -215,7 +212,6 @@ export const heatmapsBrowserLogic = kea<heatmapsBrowserLogicType>([
],
dataUrl: [
null as string | null,
{ persist: true, prefix: `${teamId}__` },
{
setDataUrl: (_, { url }) => url,
},
@@ -237,14 +233,13 @@ export const heatmapsBrowserLogic = kea<heatmapsBrowserLogicType>([
},
],
widthOverride: [
null as number | null,
1024 as number | null,
{
setIframeWidth: (_, { width }) => width,
},
],
displayUrl: [
null as string | null,
{ persist: true, prefix: `${teamId}__` },
{
setDisplayUrl: (_, { url }) => url,
},
@@ -305,12 +300,7 @@ export const heatmapsBrowserLogic = kea<heatmapsBrowserLogicType>([
listeners(({ actions, props, values, cache }) => ({
setDisplayUrl: ({ url }) => {
if (!values.dataUrl || values.dataUrl.trim() === '') {
actions.setDataUrl(url)
}
if (!url || url.trim() === '') {
actions.setDataUrl(null)
}
actions.setDataUrl(url?.trim() ?? null)
},
setReplayIframeData: ({ replayIframeData }) => {
if (replayIframeData && replayIframeData.url) {
@@ -451,6 +441,8 @@ export const heatmapsBrowserLogic = kea<heatmapsBrowserLogicType>([
if (searchParams.commonFilters && !objectsEqual(searchParams.commonFilters, values.commonFilters)) {
actions.setCommonFilters(searchParams.commonFilters as CommonFilters)
}
},
'/heatmaps/recording': (_, searchParams) => {
if (searchParams.iframeStorage) {
const replayFrameData = JSON.parse(
localStorage.getItem(searchParams.iframeStorage) || '{}'

View File

@@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'
import { mswDecorator } from '~/mocks/browser'
const meta: Meta = {
component: App,
title: 'Scenes-App/Heatmap New',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
pageUrl: urls.heatmapNew ? urls.heatmapNew() : urls.heatmap('new'),
testOptions: {
waitForLoadersToDisappear: true,
},
},
decorators: [mswDecorator({})],
}
export default meta
type Story = StoryObj<typeof meta>
export const NewForm: Story = {}

View File

@@ -0,0 +1,123 @@
import { useActions, useValues } from 'kea'
import { useDebouncedCallback } from 'use-debounce'
import { Spinner } from '@posthog/lemon-ui'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonInput } from 'lib/lemon-ui/LemonInput'
import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
import { HeatmapsForbiddenURL } from 'scenes/heatmaps/components/HeatmapsForbiddenURL'
import { HeatmapsUrlsList } from 'scenes/heatmaps/components/HeatmapsInfo'
import { HeatmapsInvalidURL } from 'scenes/heatmaps/components/HeatmapsInvalidURL'
import { urls } from 'scenes/urls'
import { ScenePanelDivider } from '~/layout/scenes/SceneLayout'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { SceneDivider } from '~/layout/scenes/components/SceneDivider'
import { SceneSection } from '~/layout/scenes/components/SceneSection'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { HeatmapType } from '~/types'
import { heatmapLogic } from './heatmapLogic'
export function HeatmapNewScene(): JSX.Element {
const logic = heatmapLogic({ id: 'new' })
const { loading, displayUrl, isDisplayUrlValid, type, name, dataUrl, isBrowserUrlAuthorized } = useValues(logic)
const { setDisplayUrl, setType, setName, createHeatmap, setDataUrl } = useActions(logic)
const debouncedOnNameChange = useDebouncedCallback((name: string) => {
setName(name)
}, 500)
if (loading) {
return (
<SceneContent>
<Spinner />
</SceneContent>
)
}
return (
<SceneContent>
<SceneTitleSection
name={name}
resourceType={{
type: 'heatmap',
}}
description={null}
canEdit
forceEdit
onNameChange={debouncedOnNameChange}
forceBackTo={{
name: 'Heatmaps',
path: urls.heatmaps(),
key: 'heatmaps',
}}
/>
<ScenePanelDivider />
<SceneSection title="Page URL" description="URL to your website">
<LemonInput value={displayUrl || ''} onChange={setDisplayUrl} placeholder="https://www.example.com" />
{!isDisplayUrlValid ? <HeatmapsInvalidURL /> : null}
{displayUrl === '' ? <HeatmapsUrlsList /> : null}
</SceneSection>
<SceneDivider />
<SceneSection
title="Heatmap data URL"
description="An exact match or a pattern for heatmap data. For example, use a pattern if you have pages with dynamic IDs. E.g. https://www.example.com/users/* will aggregate data from all pages under /users/."
>
<LemonInput
size="small"
placeholder="https://www.example.com/*"
value={dataUrl ?? ''}
onChange={(value) => {
setDataUrl(value || null)
}}
fullWidth={true}
/>
<div className="text-xs text-muted mt-1">Add * for wildcards to aggregate data from multiple pages</div>
{dataUrl && !isBrowserUrlAuthorized ? <HeatmapsForbiddenURL /> : null}
</SceneSection>
<SceneDivider />
<SceneSection title="Capture method" description="Choose how to display your page in the heatmap">
<LemonRadio
options={[
{
label: 'Screenshot',
value: 'screenshot',
description: 'We will generate a full-page screenshot of your website',
},
{
label: 'Iframe',
value: 'iframe',
description:
'We will load your website in an iframe. Make sure you allow your website to be loaded in an iframe.',
},
]}
value={type}
onChange={(value: HeatmapType) => setType(value)}
/>
</SceneSection>
<SceneDivider />
<div className="flex gap-2">
<LemonButton
className="w-fit"
type="primary"
data-attr="save-heatmap"
onClick={createHeatmap}
loading={false}
disabledReason={
!isDisplayUrlValid || !isBrowserUrlAuthorized
? 'Invalid URL or forbidden URL'
: !displayUrl
? 'URL is required'
: !dataUrl
? 'Heatmap data URL is required'
: null
}
>
Save
</LemonButton>
</div>
</SceneContent>
)
}

View File

@@ -1,16 +1,15 @@
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { HeatmapRecording } from 'scenes/heatmaps/components/HeatmapRecording'
import { heatmapRecordingLogic } from 'scenes/heatmaps/scenes/heatmap/heatmapRecordingLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { HeatmapsBrowser } from './HeatmapsBrowser'
import { heatmapsSceneLogic } from './heatmapsSceneLogic'
export const scene: SceneExport = {
component: HeatmapsScene,
logic: heatmapsSceneLogic,
component: HeatmapRecordingScene,
logic: heatmapRecordingLogic,
settingSectionId: 'environment-autocapture',
}
export function HeatmapsScene(): JSX.Element {
export function HeatmapRecordingScene(): JSX.Element {
return (
<div>
<LemonBanner
@@ -24,7 +23,7 @@ export function HeatmapsScene(): JSX.Element {
directly to us!
</p>
</LemonBanner>
<HeatmapsBrowser />
<HeatmapRecording />
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { Meta, StoryObj } from '@storybook/react'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'
import { mswDecorator } from '~/mocks/browser'
const generatingSaved = {
id: 100,
short_id: 'hm_gen',
name: 'Generating…',
url: 'https://example.com',
data_url: 'https://example.com',
target_widths: [768, 1024],
type: 'screenshot',
status: 'processing',
has_content: false,
snapshots: [],
deleted: false,
created_by: { id: 1, uuid: 'user-1', distinct_id: 'd1', first_name: 'Alice', email: 'alice@ph.com' },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
exception: null,
}
const meta: Meta = {
component: App,
title: 'Scenes-App/Heatmap',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
pageUrl: urls.heatmap('hm_gen'),
testOptions: {
waitForLoadersToDisappear: true,
},
},
decorators: [
mswDecorator({
get: {
'/api/environments/:team_id/saved/hm_gen/': generatingSaved,
'/api/environments/:team_id/heatmap_screenshots/:id/content/': (_req, res, ctx) =>
res(ctx.status(202), ctx.json(generatingSaved)),
},
}),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const Generating: Story = {
parameters: {
testOptions: {
waitForLoadersToDisappear: false,
},
},
}
const iframeSaved = {
id: 101,
short_id: 'hm_iframe',
name: 'Iframe example.com',
url: 'https://example.com',
data_url: 'https://example.com',
target_widths: [],
type: 'iframe',
status: 'completed',
has_content: false,
snapshots: [],
deleted: false,
created_by: { id: 1, uuid: 'user-1', distinct_id: 'd1', first_name: 'Alice', email: 'alice@ph.com' },
created_at: '2024-01-03T00:00:00Z',
updated_at: '2024-01-03T00:00:00Z',
exception: null,
}
export const IframeExample: Story = {
parameters: {
pageUrl: urls.heatmap('hm_iframe'),
},
decorators: [
mswDecorator({
get: {
'/api/environments/:team_id/saved/hm_iframe/': iframeSaved,
},
}),
],
}
export const New: Story = {
parameters: {
pageUrl: urls.heatmap('new'),
},
}

View File

@@ -0,0 +1,141 @@
import { BindLogic, useActions, useValues } from 'kea'
import { IconBrowser } from '@posthog/icons'
import { Spinner } from '@posthog/lemon-ui'
import { HeatmapCanvas } from 'lib/components/heatmaps/HeatmapCanvas'
import { FilmCameraHog } from 'lib/components/hedgehogs'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LoadingBar } from 'lib/lemon-ui/LoadingBar'
import { FilterPanel } from 'scenes/heatmaps/components/FilterPanel'
import { HeatmapHeader } from 'scenes/heatmaps/components/HeatmapHeader'
import { urls } from 'scenes/urls'
import { ScenePanelDivider } from '~/layout/scenes/SceneLayout'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { heatmapLogic } from './heatmapLogic'
export function HeatmapScene({ id }: { id: string }): JSX.Element {
const logicProps = { id: id }
const logic = heatmapLogic(logicProps)
const { name, loading, type, displayUrl, widthOverride, screenshotUrl, generatingScreenshot, screenshotLoaded } =
useValues(logic)
const { setName, updateHeatmap, onIframeLoad, setScreenshotLoaded } = useActions(logic)
if (loading) {
return (
<SceneContent>
<Spinner />
</SceneContent>
)
}
return (
<BindLogic logic={heatmapLogic} props={logicProps}>
<SceneContent>
<SceneTitleSection
name={name || 'No name'}
resourceType={{
type: 'heatmap',
}}
description={null}
canEdit
onNameChange={setName}
forceBackTo={{
name: 'Heatmaps',
path: urls.heatmaps(),
key: 'heatmaps',
}}
actions={
<>
<LemonButton type="primary" onClick={updateHeatmap} size="small">
Save
</LemonButton>
</>
}
/>
<ScenePanelDivider />
<HeatmapHeader />
<FilterPanel />
<ScenePanelDivider />
<div className="border mx-auto bg-white rounded-lg" style={{ width: widthOverride ?? '100%' }}>
<div className="p-2 border-b text-muted-foreground gap-x-2 flex items-center">
<IconBrowser /> {displayUrl}
</div>
{type === 'screenshot' ? (
<div
className="relative flex w-full justify-center flex-1"
style={{ width: widthOverride ?? '100%' }}
>
{generatingScreenshot ? (
<div className="flex-1 flex items-center justify-center min-h-96">
<style>{`@keyframes hog-wobble{from{transform:rotate(0deg)}to{transform:rotate(5deg)}}`}</style>
<div className="text-sm text-center font-semibold">
<FilmCameraHog
className="w-32 h-32 mx-auto mb-2"
style={{
animation: 'hog-wobble 1.2s ease-in-out infinite alternate',
transformOrigin: '50% 50%',
}}
/>
Taking screenshots of your page...
<div className="text-muted text-xs mt-2">This usually takes a few minutes</div>
<LoadingBar />
</div>
</div>
) : screenshotUrl ? (
<>
{screenshotLoaded && (
<HeatmapCanvas
key={widthOverride ?? 'auto'}
positioning="absolute"
widthOverride={widthOverride}
context="in-app"
/>
)}
<img
id="heatmap-screenshot"
src={screenshotUrl}
style={{
maxWidth: widthOverride ?? '100%',
height: 'auto',
display: 'block',
}}
onLoad={() => {
setScreenshotLoaded(true)
}}
className="rounded-b-lg border-l border-r border-b"
onError={() => {
console.error('Failed to load screenshot')
}}
/>
</>
) : null}
</div>
) : (
<div className="relative min-h-screen">
<HeatmapCanvas positioning="absolute" widthOverride={widthOverride} context="in-app" />
<iframe
id="heatmap-iframe"
className="min-h-screen bg-white rounded-b-lg"
// eslint-disable-next-line react/forbid-dom-props
style={{ width: widthOverride ?? '100%' }}
src={displayUrl || ''}
onLoad={onIframeLoad}
// these two sandbox values are necessary so that the site and toolbar can run
// this is a very loose sandbox,
// but we specify it so that at least other capabilities are denied
sandbox="allow-scripts allow-same-origin"
// we don't allow things such as camera access though
allow=""
/>
</div>
)}
</div>
</SceneContent>
</BindLogic>
)
}

View File

@@ -0,0 +1,205 @@
import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { router } from 'kea-router'
import api from 'lib/api'
import { heatmapDataLogic } from 'lib/components/heatmaps/heatmapDataLogic'
import { heatmapsBrowserLogic } from 'scenes/heatmaps/components/heatmapsBrowserLogic'
import { heatmapsSceneLogic } from 'scenes/heatmaps/scenes/heatmaps/heatmapsSceneLogic'
import { HeatmapStatus, HeatmapType } from '~/types'
import type { heatmapLogicType } from './heatmapLogicType'
export const heatmapLogic = kea<heatmapLogicType>([
path(['scenes', 'heatmaps', 'scenes', 'heatmap', 'heatmapLogic']),
props({ id: 'new' as string | number }),
key((props) => props.id),
connect(() => ({
values: [heatmapsBrowserLogic, ['dataUrl', 'displayUrl', 'isBrowserUrlAuthorized', 'widthOverride']],
actions: [
heatmapsBrowserLogic,
['setDataUrl', 'setDisplayUrl', 'onIframeLoad', 'setIframeWidth'],
heatmapsSceneLogic,
['loadSavedHeatmaps'],
heatmapDataLogic,
['loadHeatmap'],
],
})),
actions({
load: true,
createHeatmap: true,
updateHeatmap: true,
setLoading: (loading: boolean) => ({ loading }),
setType: (type: HeatmapType) => ({ type }),
setWidth: (width: number) => ({ width }),
setName: (name: string) => ({ name }),
setScreenshotUrl: (url: string | null) => ({ url }),
setScreenshotError: (error: string | null) => ({ error }),
setGeneratingScreenshot: (generating: boolean) => ({ generating }),
pollScreenshotStatus: (id: number, width?: number) => ({ id, width }),
setHeatmapId: (id: number | null) => ({ id }),
setScreenshotLoaded: (screenshotLoaded: boolean) => ({ screenshotLoaded }),
}),
reducers({
type: ['screenshot' as HeatmapType, { setType: (_, { type }) => type }],
width: [1024 as number | null, { setWidth: (_, { width }) => width }],
name: ['New heatmap', { setName: (_, { name }) => name }],
loading: [false, { setLoading: (_, { loading }) => loading }],
status: ['processing' as HeatmapStatus, { setStatus: (_, { status }) => status }],
screenshotUrl: [null as string | null, { setScreenshotUrl: (_, { url }) => url }],
screenshotError: [null as string | null, { setScreenshotError: (_, { error }) => error }],
generatingScreenshot: [false, { setGeneratingScreenshot: (_, { generating }) => generating }],
// expose a screenshotLoading alias for UI compatibility
screenshotLoading: [false as boolean, { setScreenshotUrl: () => false }],
heatmapId: [null as number | null, { setHeatmapId: (_, { id }) => id }],
screenshotLoaded: [false, { setScreenshotLoaded: (_, { screenshotLoaded }) => screenshotLoaded }],
}),
listeners(({ actions, values, props }) => ({
load: async () => {
if (!props.id || String(props.id) === 'new') {
return
}
actions.setLoading(true)
try {
const item = await api.savedHeatmaps.get(props.id)
actions.setHeatmapId(item.id)
actions.setName(item.name)
actions.setDisplayUrl(item.url)
actions.setDataUrl(item.data_url)
actions.setType(item.type)
if (item.type === 'screenshot') {
const desiredWidth = values.widthOverride ?? 1024
if (item.status === 'completed' && item.has_content) {
actions.setScreenshotUrl(
`/api/environments/${window.POSTHOG_APP_CONTEXT?.current_team?.id}/heatmap_screenshots/${item.id}/content/?width=${desiredWidth}`
)
// trigger heatmap overlay load
actions.loadHeatmap()
} else if (item.status === 'failed') {
actions.setScreenshotError(item.exception || 'Screenshot generation failed')
} else {
actions.setScreenshotError(null)
actions.pollScreenshotStatus(item.id, desiredWidth)
}
}
} finally {
actions.setLoading(false)
}
},
// React to viewport width changes by updating the image URL directly
setIframeWidth: async ({ width }) => {
if (values.type !== 'screenshot' || !values.heatmapId) {
return
}
const w = width ?? values.widthOverride ?? 1024
actions.setScreenshotError(null)
actions.setScreenshotUrl(
`/api/environments/${window.POSTHOG_APP_CONTEXT?.current_team?.id}/heatmap_screenshots/${values.heatmapId}/content/?width=${w}`
)
actions.loadHeatmap()
},
pollScreenshotStatus: async ({ id, width }, breakpoint) => {
let attempts = 0
actions.setGeneratingScreenshot(true)
const maxAttempts = 60
while (attempts < maxAttempts) {
await breakpoint(2000)
try {
const contentResponse = await api.heatmapScreenshots.getContent(id)
if (contentResponse.success) {
const w = width ?? 1024
actions.setScreenshotUrl(
`/api/environments/${window.POSTHOG_APP_CONTEXT?.current_team?.id}/heatmap_screenshots/${id}/content/?width=${w}`
)
actions.loadHeatmap()
actions.setGeneratingScreenshot(false)
break
} else {
const screenshot = contentResponse.data
if (screenshot.status === 'completed' && screenshot.has_content) {
const w = width ?? 1024
actions.setScreenshotUrl(
`/api/environments/${window.POSTHOG_APP_CONTEXT?.current_team?.id}/heatmap_screenshots/${screenshot.id}/content/?width=${w}`
)
actions.loadHeatmap()
actions.setGeneratingScreenshot(false)
break
} else if (screenshot.status === 'failed') {
actions.setScreenshotError(
screenshot.exception || (screenshot as any).error || 'Screenshot generation failed'
)
actions.setGeneratingScreenshot(false)
break
}
}
attempts++
} catch (e) {
actions.setScreenshotError('Failed to check screenshot status')
console.error(e)
break
}
}
if (attempts >= maxAttempts) {
actions.setScreenshotError('Screenshot generation timed out')
}
},
createHeatmap: async () => {
actions.setLoading(true)
try {
const data = {
name: values.name,
url: values.displayUrl || '',
data_url: values.dataUrl,
type: values.type,
}
const created = await api.savedHeatmaps.create(data)
actions.loadSavedHeatmaps()
// Navigate to the created heatmap detail page
router.actions.push(`/heatmaps/${created.short_id}`)
} finally {
actions.setLoading(false)
}
},
updateHeatmap: async () => {
actions.setLoading(true)
try {
const data = {
name: values.name,
url: values.displayUrl || '',
data_url: values.dataUrl,
type: values.type,
}
await api.savedHeatmaps.update(props.id, data)
} finally {
actions.setLoading(false)
}
},
})),
selectors({
isDisplayUrlValid: [
(s) => [s.displayUrl],
(displayUrl) => {
if (!displayUrl) {
// an empty dataUrl is valid
// since we just won't do anything with it
return true
}
try {
// must be something that can be parsed as a URL
new URL(displayUrl)
// and must be a valid URL that our redirects can cope with
// this is a very loose check, but `http:/blaj` is not valid for PostHog
// but survives new URL(http:/blaj)
return displayUrl.includes('://')
} catch {
return false
}
},
],
}),
afterMount(({ actions }) => {
actions.load()
}),
])

View File

@@ -6,10 +6,10 @@ import { urls } from 'scenes/urls'
import { Breadcrumb } from '~/types'
import type { heatmapsSceneLogicType } from './heatmapsSceneLogicType'
import type { heatmapRecordingLogicType } from './heatmapRecordingLogicType'
export const heatmapsSceneLogic = kea<heatmapsSceneLogicType>([
path(['scenes', 'heatmaps', 'heatmapsSceneLogic']),
export const heatmapRecordingLogic = kea<heatmapRecordingLogicType>([
path(['scenes', 'heatmaps', 'scenes', 'heatmap', 'heatmapRecordingLogic']),
selectors(() => ({
breadcrumbs: [
() => [],

View File

@@ -0,0 +1,74 @@
import { Meta, StoryObj } from '@storybook/react'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'
import { mswDecorator } from '~/mocks/browser'
const savedList = {
count: 2,
results: [
{
id: 1,
short_id: 'hm_abc123',
name: 'Homepage heatmap',
url: 'https://posthog.com',
data_url: 'https://posthog.com',
target_widths: [768, 1024, 1440],
type: 'screenshot',
status: 'completed',
has_content: true,
snapshots: [
{ width: 768, has_content: true },
{ width: 1024, has_content: true },
],
deleted: false,
created_by: { id: 1, uuid: 'user-1', distinct_id: 'd1', first_name: 'Alice', email: 'alice@ph.com' },
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
exception: null,
},
{
id: 2,
short_id: 'hm_def456',
name: 'Pricing page',
url: 'https://posthog.com/pricing',
data_url: 'https://posthog.com/pricing',
target_widths: [1024, 1440],
type: 'iframe',
status: 'completed',
has_content: false,
snapshots: [],
deleted: false,
created_by: { id: 2, uuid: 'user-2', distinct_id: 'd2', first_name: 'Bob', email: 'bob@ph.com' },
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
exception: null,
},
],
}
const meta: Meta = {
component: App,
title: 'Scenes-App/Heatmaps Saved',
parameters: {
layout: 'fullscreen',
viewMode: 'story',
pageUrl: urls.heatmaps(),
testOptions: {
waitForLoadersToDisappear: true,
},
},
decorators: [
mswDecorator({
get: {
'/api/environments/:team_id/saved/': savedList,
},
}),
],
}
export default meta
type Story = StoryObj<typeof meta>
export const List: Story = {}

View File

@@ -0,0 +1,177 @@
import { useActions, useValues } from 'kea'
import { IconPlusSmall } from '@posthog/icons'
import { LemonButton, LemonInput, LemonTable, LemonTableColumn, LemonTableColumns, Link } from '@posthog/lemon-ui'
import { MemberSelect } from 'lib/components/MemberSelect'
import { TZLabel } from 'lib/components/TZLabel'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { More } from 'lib/lemon-ui/LemonButton/More'
import { createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
import { HeatmapsWarnings } from 'scenes/heatmaps/components/HeatmapsWarnings'
import { Scene, SceneExport } from 'scenes/sceneTypes'
import { sceneConfigurations } from 'scenes/scenes'
import { urls } from 'scenes/urls'
import { SceneContent } from '~/layout/scenes/components/SceneContent'
import { SceneDivider } from '~/layout/scenes/components/SceneDivider'
import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection'
import { HeatmapScreenshotType } from '~/types'
import { HEATMAPS_PER_PAGE, heatmapsSceneLogic } from './heatmapsSceneLogic'
export const scene: SceneExport = {
component: HeatmapsScene,
logic: heatmapsSceneLogic,
settingSectionId: 'environment-autocapture',
}
export function HeatmapsScene(): JSX.Element {
const { savedHeatmaps, savedHeatmapsLoading, filters, totalCount } = useValues(heatmapsSceneLogic)
const { deleteHeatmap, setHeatmapsFilters } = useActions(heatmapsSceneLogic)
const columns: LemonTableColumns<HeatmapScreenshotType> = [
{
title: 'Name',
dataIndex: 'name',
render: (_, row) => (
<Link to={urls.heatmap(row.short_id)}>
<span className="truncate max-w-[32rem] inline-block align-middle">{row.name}</span>
</Link>
),
},
{
title: 'Page',
dataIndex: 'url',
render: (_, row) => (
<Link to={urls.heatmap(row.short_id)}>
<span className="truncate max-w-[32rem] inline-block align-middle">{row.url}</span>
</Link>
),
},
{
title: 'Heatmap data URL',
dataIndex: 'data_url',
render: (_, row) => (
<Link to={urls.heatmap(row.short_id)}>
<span className="truncate max-w-[32rem] inline-block align-middle">{row.data_url}</span>
</Link>
),
},
{
title: 'Type',
dataIndex: 'type',
render: (_, row) => row.type.charAt(0).toUpperCase() + row.type.slice(1),
},
{
title: 'Created',
dataIndex: 'created_at',
render: function Render(created_at) {
return <div>{created_at && typeof created_at === 'string' && <TZLabel time={created_at} />}</div>
},
},
{
...(createdByColumn<HeatmapScreenshotType>() as LemonTableColumn<
HeatmapScreenshotType,
keyof HeatmapScreenshotType | undefined
>),
width: 0,
},
{
width: 0,
render: function Render(_, row) {
return (
<More
overlay={
<>
<LemonButton
status="danger"
onClick={() => deleteHeatmap(row.short_id)}
fullWidth
loading={savedHeatmapsLoading}
>
Delete
</LemonButton>
</>
}
/>
)
},
},
]
return (
<SceneContent>
<HeatmapsWarnings />
<SceneTitleSection
name={sceneConfigurations[Scene.Heatmaps].name}
description={sceneConfigurations[Scene.Heatmaps].description}
resourceType={{
type: sceneConfigurations[Scene.Heatmaps].iconType || 'default',
}}
actions={
<LemonButton
type="primary"
to={urls.heatmap('new')}
data-attr="heatmaps-new-heatmap-button"
size="small"
icon={<IconPlusSmall />}
>
New heatmap
</LemonButton>
}
/>
<SceneDivider />
<LemonBanner
type="info"
dismissKey="heatmaps-beta-banner"
className="mb-4"
action={{ children: 'Send feedback', id: 'heatmaps-feedback-button' }}
>
<p>
Heatmaps is in beta. Please let us know what you'd like to see here and/or report any issues
directly to us!
</p>
</LemonBanner>
<div className="flex justify-between gap-2 items-center flex-wrap">
<LemonInput
type="search"
placeholder="Search for heatmaps"
onChange={(value) => setHeatmapsFilters({ ...filters, search: value || '' })}
value={filters.search || ''}
/>
<div className="flex items-center gap-2">
<span>Created by:</span>
<MemberSelect
value={filters.createdBy === 'All users' ? null : (filters.createdBy as string | number | null)}
onChange={(user) => setHeatmapsFilters({ ...filters, createdBy: user?.id || 'All users' })}
/>
</div>
</div>
<div className="mb-4">
<LemonTable
dataSource={savedHeatmaps}
loading={savedHeatmapsLoading}
columns={columns}
rowKey="id"
pagination={{
controlled: true,
pageSize: HEATMAPS_PER_PAGE,
currentPage: filters.page,
entryCount: totalCount,
onBackward:
(filters.page || 1) > 1
? () => setHeatmapsFilters({ ...filters, page: Math.max(1, (filters.page || 1) - 1) })
: undefined,
onForward:
(filters.page || 1) * HEATMAPS_PER_PAGE < (totalCount || 0)
? () => setHeatmapsFilters({ ...filters, page: (filters.page || 1) + 1 })
: undefined,
}}
nouns={['heatmap', 'heatmaps']}
/>
</div>
</SceneContent>
)
}

View File

@@ -0,0 +1,117 @@
import { actions, afterMount, kea, listeners, path, reducers, selectors } from 'kea'
import api from 'lib/api'
import { objectsEqual } from 'lib/utils'
import { deleteWithUndo } from 'lib/utils/deleteWithUndo'
import { Scene } from 'scenes/sceneTypes'
import { sceneConfigurations } from 'scenes/scenes'
import { urls } from 'scenes/urls'
import { Breadcrumb, HeatmapSavedFilters, HeatmapScreenshotType } from '~/types'
import type { heatmapsSceneLogicType } from './heatmapsSceneLogicType'
export const DEFAULT_HEATMAP_FILTERS = {
createdBy: 'All users',
search: '',
page: 1,
order: '-created_at',
}
export const HEATMAPS_PER_PAGE = 30
export const heatmapsSceneLogic = kea<heatmapsSceneLogicType>([
path(['scenes', 'heatmaps', 'scenes', 'heatmaps', 'heatmapsSceneLogic']),
actions({
loadSavedHeatmaps: true,
setSavedHeatmaps: (items: HeatmapScreenshotType[]) => ({ items }),
setLoading: (loading: boolean) => ({ loading }),
deleteHeatmap: (short_id: string) => ({ short_id }),
setHeatmapsFilters: (filters: HeatmapSavedFilters) => ({ filters }),
setTotalCount: (count: number) => ({ count }),
}),
reducers({
savedHeatmaps: [
[] as HeatmapScreenshotType[],
{
setSavedHeatmaps: (_, { items }) => items,
},
],
savedHeatmapsLoading: [
false,
{
setLoading: (_, { loading }) => loading,
},
],
filters: [
DEFAULT_HEATMAP_FILTERS as HeatmapSavedFilters,
{ persist: true },
{
setHeatmapsFilters: (_, { filters }) => filters,
},
],
totalCount: [
0 as number,
{
setTotalCount: (_, { count }) => count,
},
],
}),
selectors(() => ({
breadcrumbs: [
() => [],
(): Breadcrumb[] => {
return [
{
key: Scene.Heatmaps,
name: sceneConfigurations[Scene.Heatmaps].name || 'Heatmaps',
path: urls.heatmaps(),
iconType: sceneConfigurations[Scene.Heatmaps].iconType || 'default_icon_type',
},
]
},
],
})),
listeners(({ actions, values }) => ({
loadSavedHeatmaps: async (_, breakpoint) => {
if (!objectsEqual(values.filters, DEFAULT_HEATMAP_FILTERS)) {
await breakpoint(300)
}
actions.setLoading(true)
try {
const f = values.filters || {}
const createdBy = f.createdBy === 'All users' ? undefined : f.createdBy
const params: HeatmapSavedFilters = {
search: f.search || '',
createdBy: createdBy || 'All users',
page: f.page || 1,
order: f.order || '-created_at',
limit: HEATMAPS_PER_PAGE,
offset: Math.max(0, (f.page - 1 || 0) * HEATMAPS_PER_PAGE),
}
const response = await api.savedHeatmaps.list(params)
actions.setSavedHeatmaps(response.results || [])
actions.setTotalCount(response.count || 0)
} finally {
actions.setLoading(false)
}
},
deleteHeatmap: async ({ short_id }) => {
const item = values.savedHeatmaps.find((h: HeatmapScreenshotType) => h.short_id === short_id)
const object = { id: item?.id, short_id, name: item?.name || item?.url || 'Heatmap' }
await deleteWithUndo({
object,
idField: 'short_id',
// project/environment-scoped API path; backend must support soft-delete via PATCH
endpoint: 'environments/@current/saved',
callback: () => actions.loadSavedHeatmaps(),
})
},
setHeatmapsFilters: () => {
actions.loadSavedHeatmaps()
},
})),
afterMount(({ actions }) => {
actions.loadSavedHeatmaps()
}),
])

View File

@@ -62,6 +62,9 @@ export enum Scene {
Groups = 'Groups',
GroupsNew = 'GroupsNew',
Heatmaps = 'Heatmaps',
Heatmap = 'Heatmap',
HeatmapNew = 'HeatmapNew',
HeatmapRecording = 'HeatmapRecording',
HogFunction = 'HogFunction',
Insight = 'Insight',
IntegrationsRedirect = 'IntegrationsRedirect',

View File

@@ -232,6 +232,21 @@ export const sceneConfigurations: Record<Scene | string, SceneConfig> = {
iconType: 'heatmap',
description: 'Heatmaps are a way to visualize user behavior on your website.',
},
[Scene.Heatmap]: {
projectBased: true,
name: 'Heatmap',
iconType: 'heatmap',
},
[Scene.HeatmapNew]: {
projectBased: true,
name: 'New heatmap',
iconType: 'heatmap',
},
[Scene.HeatmapRecording]: {
projectBased: true,
name: 'Heatmap recording',
iconType: 'heatmap',
},
[Scene.HogFunction]: { projectBased: true, name: 'Hog function', activityScope: ActivityScope.HOG_FUNCTION },
[Scene.Insight]: {
projectBased: true,
@@ -681,6 +696,9 @@ export const routes: Record<string, [Scene | string, string]> = {
[urls.moveToPostHogCloud()]: [Scene.MoveToPostHogCloud, 'moveToPostHogCloud'],
[urls.advancedActivityLogs()]: [Scene.AdvancedActivityLogs, 'advancedActivityLogs'],
[urls.heatmaps()]: [Scene.Heatmaps, 'heatmaps'],
[urls.heatmapNew()]: [Scene.HeatmapNew, 'heatmapNew'],
[urls.heatmapRecording()]: [Scene.HeatmapRecording, 'heatmapRecording'],
[urls.heatmap(':id')]: [Scene.Heatmap, 'heatmap'],
[urls.liveDebugger()]: [Scene.LiveDebugger, 'liveDebugger'],
[urls.links()]: [Scene.Links, 'links'],
[urls.link(':id')]: [Scene.Link, 'link'],

View File

@@ -29,7 +29,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { clamp, downloadFile, findLastIndex, objectsEqual, uuid } from 'lib/utils'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { openBillingPopupModal } from 'scenes/billing/BillingPopup'
import { ReplayIframeData } from 'scenes/heatmaps/heatmapsBrowserLogic'
import { ReplayIframeData } from 'scenes/heatmaps/components/heatmapsBrowserLogic'
import { playerCommentModel } from 'scenes/session-recordings/player/commenting/playerCommentModel'
import {
SessionRecordingDataCoordinatorLogicProps,
@@ -1732,7 +1732,7 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
url: values.currentURL,
}
localStorage.setItem(key, JSON.stringify(data))
router.actions.push(urls.heatmaps(`iframeStorage=${key}`))
router.actions.push(urls.heatmapRecording(`iframeStorage=${key}`))
},
setIsFullScreen: async ({ isFullScreen }) => {

View File

@@ -150,6 +150,10 @@ export const urls = {
moveToPostHogCloud: (): string => '/move-to-cloud',
heatmaps: (params?: string): string =>
`/heatmaps${params ? `?${params.startsWith('?') ? params.slice(1) : params}` : ''}`,
heatmapNew: (): string => `/heatmaps/new`,
heatmapRecording: (params?: string): string =>
`/heatmaps/recording${params ? `?${params.startsWith('?') ? params.slice(1) : params}` : ''}`,
heatmap: (id: string | number): string => `/heatmaps/${id}`,
links: (params?: string): string =>
`/links${params ? `?${params.startsWith('?') ? params.slice(1) : params}` : ''}`,
link: (id: string): string => `/link/${id}`,

View File

@@ -4705,6 +4705,7 @@ export enum ActivityScope {
EXTERNAL_DATA_SOURCE = 'ExternalDataSource',
EXTERNAL_DATA_SCHEMA = 'ExternalDataSchema',
ENDPOINT = 'Endpoint',
HEATMAP = 'Heatmap',
}
export type CommentType = {
@@ -5867,6 +5868,39 @@ export interface DataWarehouseActivityRecord {
workflow_run_id?: string
}
export type HeatmapType = 'screenshot' | 'iframe' | 'recording'
export type HeatmapStatus = 'processing' | 'completed' | 'failed'
export interface HeatmapScreenshotType {
id: number
name: string
short_id: string
url: string
data_url: string | null
type: HeatmapType
width: number
status: HeatmapStatus
has_content: boolean
created_at: string
updated_at: string
exception?: string
error?: string // Added for error responses from content endpoint
created_by?: UserBasicType | null
}
export type HeatmapScreenshotContentResponse =
| { success: true; data: Response } // 200: PNG image data
| { success: false; data: HeatmapScreenshotType } // 202/404/501: JSON with screenshot metadata
export interface HeatmapSavedFilters {
order: string
search: string
createdBy: number | 'All users'
page: number
limit: number
offset: number
}
export interface DataWarehouseDashboardDataSource {
id: string
name: string

View File

@@ -57,7 +57,7 @@ from products.workflows.backend.api import MessageCategoryViewSet, MessagePrefer
from ee.api.vercel import vercel_installation, vercel_product, vercel_resource
from ..heatmaps.heatmaps_api import HeatmapViewSet, LegacyHeatmapViewSet
from ..heatmaps.heatmaps_api import HeatmapScreenshotViewSet, HeatmapViewSet, LegacyHeatmapViewSet, SavedHeatmapViewSet
from ..session_recordings.session_recording_api import SessionRecordingViewSet
from ..session_recordings.session_recording_playlist_api import SessionRecordingPlaylistViewSet
from ..taxonomy import property_definition_api
@@ -571,6 +571,10 @@ register_grandfathered_environment_nested_viewset(
)
register_grandfathered_environment_nested_viewset(r"heatmaps", HeatmapViewSet, "environment_heatmaps", ["team_id"])
register_grandfathered_environment_nested_viewset(
r"heatmap_screenshots", HeatmapScreenshotViewSet, "environment_heatmap_screenshots", ["team_id"]
)
register_grandfathered_environment_nested_viewset(r"saved", SavedHeatmapViewSet, "environment_saved", ["team_id"])
register_grandfathered_environment_nested_viewset(r"sessions", SessionViewSet, "environment_sessions", ["team_id"])
if EE_AVAILABLE:

View File

@@ -1,5 +1,9 @@
from datetime import date, datetime
from typing import Any, List, Literal # noqa: UP035
from typing import Any, List, Literal, cast # noqa: UP035
from django.core.exceptions import FieldError
from django.db.models import Q
from django.http import HttpResponse
from rest_framework import request, response, serializers, status, viewsets
@@ -14,9 +18,22 @@ from posthog.hogql.filters import replace_filters
from posthog.hogql.parser import parse_expr, parse_select
from posthog.hogql.query import execute_hogql_query
from posthog.api.forbid_destroy_model import ForbidDestroyModel
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
from posthog.api.utils import action
from posthog.auth import TemporaryTokenAuthentication
from posthog.rate_limit import ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle
from posthog.heatmaps.heatmaps_utils import DEFAULT_TARGET_WIDTHS, is_url_allowed
from posthog.models import User
from posthog.models.activity_logging.activity_log import Detail, log_activity
from posthog.models.heatmap_saved import SavedHeatmap
from posthog.rate_limit import (
AIBurstRateThrottle,
AISustainedRateThrottle,
ClickHouseBurstRateThrottle,
ClickHouseSustainedRateThrottle,
)
from posthog.tasks.heatmap_screenshot import generate_heatmap_screenshot
from posthog.utils import relative_date_parse_with_delta_mapping
DEFAULT_QUERY = """
@@ -282,3 +299,256 @@ class HeatmapViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet):
class LegacyHeatmapViewSet(HeatmapViewSet):
param_derived_from_user_current_team = "team_id"
# Heatmap Screenshot functionality
class HeatmapScreenshotResponseSerializer(serializers.ModelSerializer):
created_by = UserBasicSerializer(read_only=True)
snapshots = serializers.SerializerMethodField()
class Meta:
model = SavedHeatmap
fields = [
"id",
"short_id",
"name",
"url",
"data_url",
"target_widths",
"type",
"status",
"has_content",
"snapshots",
"deleted",
"created_by",
"created_at",
"updated_at",
"exception",
]
read_only_fields = [
"id",
"short_id",
"status",
"has_content",
"created_by",
"created_at",
"updated_at",
"exception",
]
def get_snapshots(self, obj: SavedHeatmap) -> list[dict]:
# Expose metadata of generated snapshots (width + readiness)
snaps = []
for snap in obj.snapshots.all():
snaps.append(
{
"width": snap.width,
"has_content": bool(snap.content or snap.content_location),
}
)
snaps.sort(key=lambda s: s["width"])
return snaps
class HeatmapScreenshotViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet):
scope_object = "INTERNAL"
throttle_classes = [ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle]
serializer_class = HeatmapScreenshotResponseSerializer
authentication_classes = [TemporaryTokenAuthentication]
queryset = SavedHeatmap.objects.all()
def safely_get_queryset(self, queryset):
return queryset.filter(team=self.team)
@action(methods=["GET"], detail=True)
def content(self, request: request.Request, *args: Any, **kwargs: Any) -> HttpResponse:
screenshot = self.get_object()
if screenshot.deleted:
return response.Response(status=status.HTTP_404_NOT_FOUND)
# Pick requested width or default
try:
requested_width = int(request.query_params.get("width", 1024))
except (ValueError, TypeError):
return response.Response(
{"error": "Invalid width parameter, must be an integer"}, status=status.HTTP_400_BAD_REQUEST
)
# Try exact match snapshot
snapshot = screenshot.snapshots.filter(width=requested_width).first()
# If not found, pick closest by absolute difference among available snapshots
if not snapshot:
all_snaps = list(screenshot.snapshots.all())
if all_snaps:
snapshot = min(all_snaps, key=lambda s: abs(s.width - requested_width))
if not snapshot:
# Nothing generated yet
response_serializer = HeatmapScreenshotResponseSerializer(screenshot)
return response.Response(response_serializer.data, status=status.HTTP_202_ACCEPTED)
if snapshot.content:
http_response = HttpResponse(snapshot.content, content_type="image/jpeg")
http_response["Content-Disposition"] = (
f'attachment; filename="screenshot-{screenshot.id}-{snapshot.width}.jpg"'
)
return http_response
elif snapshot.content_location:
response_serializer = HeatmapScreenshotResponseSerializer(screenshot)
return response.Response(
{**response_serializer.data, "error": "Content location not implemented yet"},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
else:
response_serializer = HeatmapScreenshotResponseSerializer(screenshot)
return response.Response(response_serializer.data, status=status.HTTP_202_ACCEPTED)
class SavedHeatmapRequestSerializer(serializers.ModelSerializer):
widths = serializers.ListField(
child=serializers.IntegerField(min_value=100, max_value=3000), required=False, allow_empty=False
)
def validate_url(self, value: str) -> str:
ok, err = is_url_allowed(value)
if not ok:
raise serializers.ValidationError(err or "URL not allowed")
return value
class Meta:
model = SavedHeatmap
fields = ["name", "url", "data_url", "widths", "type", "deleted"]
extra_kwargs = {
"name": {"required": False, "allow_null": True},
"url": {"required": True},
"data_url": {"required": False, "allow_null": True},
"type": {"required": False, "default": SavedHeatmap.Type.SCREENSHOT},
"deleted": {"required": False},
}
class SavedHeatmapViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, viewsets.GenericViewSet):
scope_object = "INTERNAL"
throttle_classes = [ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle]
serializer_class = HeatmapScreenshotResponseSerializer
authentication_classes = [TemporaryTokenAuthentication]
queryset = SavedHeatmap.objects.all()
lookup_field = "short_id"
def get_throttles(self):
if self.action == "create":
# More restrictive rate limiting for expensive screenshot generation
return [AIBurstRateThrottle(), AISustainedRateThrottle()]
return super().get_throttles()
def safely_get_queryset(self, queryset):
return queryset.filter(team=self.team)
def list(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response:
qs = (
self.safely_get_queryset(self.get_queryset())
.filter(deleted=False)
.select_related("created_by")
.order_by("-updated_at")
)
type_param = request.query_params.get("type")
status_param = request.query_params.get("status")
search = request.query_params.get("search")
created_by_param = request.query_params.get("created_by")
order = request.query_params.get("order")
if type_param:
qs = qs.filter(type=type_param)
if status_param:
qs = qs.filter(status=status_param)
if search:
qs = qs.filter(Q(url__icontains=search) | Q(name__icontains=search))
if created_by_param:
try:
qs = qs.filter(created_by_id=int(created_by_param))
except (ValueError, TypeError):
return response.Response(
{"error": "Invalid created_by parameter, must be an integer"}, status=status.HTTP_400_BAD_REQUEST
)
if order:
try:
qs = qs.order_by(order)
except FieldError:
return response.Response({"error": f"Invalid order field: {order}"}, status=status.HTTP_400_BAD_REQUEST)
limit = int(request.query_params.get("limit", 100))
offset = int(request.query_params.get("offset", 0))
count = qs.count()
results = qs[offset : offset + limit]
data = HeatmapScreenshotResponseSerializer(results, many=True).data
return response.Response({"results": data, "count": count}, status=status.HTTP_200_OK)
def create(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response:
serializer = SavedHeatmapRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data.get("name")
url = serializer.validated_data["url"]
data_url = serializer.validated_data.get("data_url") or url
widths = serializer.validated_data.get("widths", DEFAULT_TARGET_WIDTHS)
heatmap_type = serializer.validated_data.get("type", SavedHeatmap.Type.SCREENSHOT)
screenshot = SavedHeatmap.objects.create(
team=self.team,
name=name,
url=url,
data_url=data_url,
target_widths=widths,
type=heatmap_type,
created_by=cast(User, request.user),
status=SavedHeatmap.Status.PROCESSING
if heatmap_type == SavedHeatmap.Type.SCREENSHOT
else SavedHeatmap.Status.COMPLETED,
)
log_activity(
organization_id=cast(User, request.user).current_organization_id
if hasattr(request.user, "current_organization_id")
else None,
team_id=self.team.id,
user=cast(User, request.user),
item_id=screenshot.short_id or str(screenshot.id),
scope="Heatmap",
activity="created",
detail=Detail(name=screenshot.name or screenshot.url, short_id=screenshot.short_id, type=screenshot.type),
was_impersonated=getattr(request, "was_impersonated", False),
)
if heatmap_type == SavedHeatmap.Type.SCREENSHOT:
generate_heatmap_screenshot.delay(screenshot.id)
return response.Response(HeatmapScreenshotResponseSerializer(screenshot).data, status=status.HTTP_201_CREATED)
def retrieve(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response:
obj = self.get_object()
return response.Response(HeatmapScreenshotResponseSerializer(obj).data, status=status.HTTP_200_OK)
def partial_update(self, request: request.Request, *args: Any, **kwargs: Any) -> response.Response:
obj = self.get_object()
serializer = SavedHeatmapRequestSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
updated = serializer.save()
log_activity(
organization_id=cast(User, request.user).current_organization_id
if hasattr(request.user, "current_organization_id")
else None,
team_id=self.team.id,
user=cast(User, request.user),
item_id=updated.short_id or str(updated.id),
scope="Heatmap",
activity="updated",
detail=Detail(name=updated.name or updated.url, short_id=updated.short_id, type=updated.type),
was_impersonated=getattr(request, "was_impersonated", False),
)
return response.Response(HeatmapScreenshotResponseSerializer(updated).data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,88 @@
import socket
import ipaddress
import urllib.parse as urlparse
from posthog.cloud_utils import is_dev_mode
# Shared constants
DEFAULT_TARGET_WIDTHS = [320, 375, 425, 768, 1024, 1440, 1920]
# URL safety helpers
DISALLOWED_SCHEMES = {"file", "ftp", "gopher", "ws", "wss", "data", "javascript"}
METADATA_HOSTS = {"169.254.169.254", "metadata.google.internal"}
def resolve_host_ips(host: str) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]:
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return set()
ips: set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set()
for _fam, *_rest, sockaddr in infos:
ip = sockaddr[0]
try:
ips.add(ipaddress.ip_address(ip))
except ValueError:
pass
return ips
def is_url_allowed(raw_url: str) -> tuple[bool, str | None]:
if is_dev_mode():
return True, None
try:
u = urlparse.urlparse(raw_url)
except Exception:
return False, "Invalid URL"
if u.scheme not in {"http", "https"} or u.scheme in DISALLOWED_SCHEMES:
return False, "Disallowed scheme"
if not u.netloc:
return False, "Missing host"
host = (u.hostname or "").lower()
if host in METADATA_HOSTS:
return False, "Local/metadata host"
if host in {"localhost", "127.0.0.1", "::1"}:
return False, "Local/Loopback host not allowed"
ips = resolve_host_ips(host)
for ip in ips:
if any(
[
ip.is_private,
ip.is_loopback,
ip.is_link_local,
ip.is_multicast,
ip.is_reserved,
ip.is_unspecified,
]
):
return False, f"Disallowed target IP: {ip}"
return True, None
def should_block_url(u: str) -> bool:
if is_dev_mode():
return False
try:
parsed = urlparse.urlparse(u)
except Exception:
return True
host = (parsed.hostname or "").lower()
if host in METADATA_HOSTS:
return True
if host in {"localhost", "127.0.0.1", "::1"}:
return True
# Quick checks for RFC1918 and link-local ranges (handles redirect chains without DNS)
if (
host.startswith("10.")
or host.startswith("192.168.")
or host.startswith("169.254.")
or host.startswith("172.16.")
or host.startswith("172.17.")
or host.startswith("172.18.")
or host.startswith("172.19.")
or host.startswith("172.2")
or host.startswith("172.30.")
or host.startswith("172.31.")
):
return True
return parsed.scheme not in {"http", "https"}

View File

@@ -0,0 +1,81 @@
from posthog.test.base import APIBaseTest
from unittest.mock import patch
from rest_framework.test import APIClient
from posthog.models import HeatmapSnapshot, SavedHeatmap, Team
class TestHeatmapsAPI(APIBaseTest):
def setUp(self):
super().setUp()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
@patch("posthog.tasks.heatmap_screenshot.generate_heatmap_screenshot.delay")
def test_generate_creates_saved_with_target_widths(self, mock_task):
resp = self.client.post(
f"/api/environments/{self.team.id}/saved/",
{"url": "https://example.com", "widths": [768, 1024]},
)
self.assertEqual(resp.status_code, 201)
saved = SavedHeatmap.objects.get(id=resp.data["id"])
self.assertEqual(saved.url, "https://example.com")
self.assertEqual(saved.created_by, self.user)
self.assertEqual(saved.status, SavedHeatmap.Status.PROCESSING)
self.assertEqual(saved.target_widths, [768, 1024])
mock_task.assert_called_once_with(saved.id)
def test_content_returns_202_until_snapshot_exists(self):
saved = SavedHeatmap.objects.create(team=self.team, url="https://example.com", created_by=self.user)
r = self.client.get(f"/api/environments/{self.team.id}/heatmap_screenshots/{saved.id}/content/?width=1024")
self.assertEqual(r.status_code, 202)
def test_content_returns_snapshot_bytes_and_defaults_width(self):
saved = SavedHeatmap.objects.create(
team=self.team,
url="https://example.com",
created_by=self.user,
status=SavedHeatmap.Status.COMPLETED,
)
HeatmapSnapshot.objects.create(heatmap=saved, width=1024, content=b"jpegdata1024")
r = self.client.get(f"/api/environments/{self.team.id}/heatmap_screenshots/{saved.id}/content/")
self.assertEqual(r.status_code, 200)
self.assertEqual(r["Content-Type"], "image/jpeg")
self.assertTrue(r["Content-Disposition"].endswith('1024.jpg"'))
self.assertEqual(r.content, b"jpegdata1024")
def test_content_picks_closest_snapshot_when_exact_missing(self):
saved = SavedHeatmap.objects.create(
team=self.team,
url="https://example.com",
created_by=self.user,
status=SavedHeatmap.Status.COMPLETED,
)
HeatmapSnapshot.objects.create(heatmap=saved, width=768, content=b"jpeg768")
HeatmapSnapshot.objects.create(heatmap=saved, width=1024, content=b"jpeg1024")
# Request 800 should pick 768 (closest)
r = self.client.get(f"/api/environments/{self.team.id}/heatmap_screenshots/{saved.id}/content/?width=800")
self.assertEqual(r.status_code, 200)
self.assertIn('768.jpg"', r["Content-Disposition"])
self.assertEqual(r.content, b"jpeg768")
def test_saved_list_excludes_deleted_and_includes_created_by(self):
SavedHeatmap.objects.create(team=self.team, url="https://a.example", created_by=self.user)
SavedHeatmap.objects.create(team=self.team, url="https://b.example", created_by=self.user, deleted=True)
r = self.client.get(f"/api/environments/{self.team.id}/saved/")
self.assertEqual(r.status_code, 200)
urls = [x["url"] for x in r.data["results"]]
self.assertIn("https://a.example", urls)
self.assertNotIn("https://b.example", urls)
# created_by present
found = next(x for x in r.data["results"] if x["url"] == "https://a.example")
self.assertEqual(found["created_by"]["id"], self.user.id)
def test_team_isolation_for_content(self):
other_team = Team.objects.create_with_data(
organization=self.organization, initiating_user=self.user, name="Other Team"
)
other = SavedHeatmap.objects.create(team=other_team, url="https://example.com")
r = self.client.get(f"/api/environments/{self.team.id}/heatmap_screenshots/{other.id}/content/")
self.assertEqual(r.status_code, 404)

View File

@@ -0,0 +1,71 @@
import ipaddress
import pytest
from posthog.heatmaps import heatmaps_utils as us
@pytest.fixture(autouse=True)
def force_prod(monkeypatch):
# Ensure tests run with production-like SSRF behavior unless overridden in a test
monkeypatch.setattr(us, "is_dev_mode", lambda: False)
class TestUrlSafety:
def test_is_url_allowed_disallowed_scheme(self):
ok, err = us.is_url_allowed("javascript:alert(1)")
assert not ok and "scheme" in (err or "")
def test_is_url_allowed_localhost(self):
ok, err = us.is_url_allowed("http://localhost")
assert not ok and "Local" in (err or "")
def test_is_url_allowed_loopback_ip(self):
ok, err = us.is_url_allowed("http://127.0.0.1")
assert not ok and "Loopback" in (err or "")
def test_is_url_allowed_metadata_host(self):
ok, err = us.is_url_allowed("http://169.254.169.254/latest/meta-data/")
assert not ok and "Local/metadata" in (err or "")
def test_dev_mode_allows_everything(self, monkeypatch):
monkeypatch.setattr(us, "is_dev_mode", lambda: True)
ok, err = us.is_url_allowed("http://localhost")
assert ok and err is None
assert us.should_block_url("http://localhost/x") is False
def test_is_url_allowed_private_resolution_blocked(self, monkeypatch):
def fake_resolve(host: str):
return {ipaddress.ip_address("192.168.1.10")}
monkeypatch.setattr(us, "resolve_host_ips", fake_resolve)
ok, err = us.is_url_allowed("https://example.com")
assert not ok and "Disallowed target IP" in (err or "")
def test_is_url_allowed_public_resolution_allowed(self, monkeypatch):
def fake_resolve(host: str):
return {ipaddress.ip_address("93.184.216.34")} # example.com public IP
monkeypatch.setattr(us, "resolve_host_ips", fake_resolve)
ok, err = us.is_url_allowed("https://example.com/path")
assert ok and err is None
@pytest.mark.parametrize(
"url,blocked",
[
("http://example.com", False),
("https://example.com/a", False),
("http://localhost/x", True),
("http://127.0.0.1/x", True),
("http://192.168.0.2/x", True),
("http://10.0.0.5/x", True),
("http://169.254.0.5/x", True),
("http://172.16.0.1/x", True),
("http://172.20.0.1/x", True),
("http://172.31.255.255/x", True),
("http://172.15.255.255/x", False), # not in RFC1918 range
("ftp://example.com", True), # non-http(s)
],
)
def test_should_block_url(self, url, blocked):
assert us.should_block_url(url) is blocked

View File

@@ -0,0 +1,111 @@
# Generated by Django 4.2.22 on 2025-10-28 15:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import posthog.utils
import posthog.models.utils
class Migration(migrations.Migration):
dependencies = [
("posthog", "0894_organizationdomain_scim_bearer_token_and_more"),
]
operations = [
migrations.CreateModel(
name="SavedHeatmap",
fields=[
(
"id",
models.UUIDField(
default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False
),
),
("short_id", models.CharField(blank=True, default=posthog.utils.generate_short_id, max_length=12)),
("name", models.CharField(blank=True, max_length=400, null=True)),
("url", models.URLField(max_length=2000)),
(
"data_url",
models.URLField(blank=True, help_text="URL for fetching heatmap data", max_length=2000, null=True),
),
("target_widths", models.JSONField(default=list)),
(
"type",
models.CharField(
choices=[("screenshot", "Screenshot"), ("iframe", "Iframe"), ("recording", "Recording")],
default="screenshot",
max_length=20,
),
),
(
"status",
models.CharField(
choices=[("processing", "Processing"), ("completed", "Completed"), ("failed", "Failed")],
default="processing",
max_length=20,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("exception", models.TextField(blank=True, null=True)),
("deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")),
],
options={
"db_table": "posthog_heatmapsaved",
},
),
migrations.CreateModel(
name="HeatmapSnapshot",
fields=[
(
"id",
models.UUIDField(
default=posthog.models.utils.uuid7, editable=False, primary_key=True, serialize=False
),
),
("width", models.IntegerField()),
("content", models.BinaryField(null=True)),
("content_location", models.TextField(blank=True, max_length=1000, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"heatmap",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="snapshots", to="posthog.savedheatmap"
),
),
],
),
migrations.AddIndex(
model_name="savedheatmap",
index=models.Index(fields=["team", "url"], name="posthog_hea_team_id_7540ce_idx"),
),
migrations.AddIndex(
model_name="savedheatmap",
index=models.Index(fields=["status"], name="posthog_hea_status_851064_idx"),
),
migrations.AddIndex(
model_name="savedheatmap",
index=models.Index(fields=["deleted"], name="posthog_hea_deleted_9942b0_idx"),
),
migrations.AlterUniqueTogether(
name="savedheatmap",
unique_together={("team", "short_id")},
),
migrations.AddIndex(
model_name="heatmapsnapshot",
index=models.Index(fields=["heatmap", "width"], name="posthog_hea_heatmap_9543e8_idx"),
),
migrations.AlterUniqueTogether(
name="heatmapsnapshot",
unique_together={("heatmap", "width")},
),
]

View File

@@ -1 +1 @@
0894_organizationdomain_scim_bearer_token_and_more
0895_savedheatmap_heatmapsnapshot_and_more

View File

@@ -41,6 +41,7 @@ from .filters import Filter, RetentionFilter
from .group import Group
from .group_usage_metric import GroupUsageMetric
from .group_type_mapping import GroupTypeMapping
from .heatmap_saved import SavedHeatmap, HeatmapSnapshot
from .host_definition import HostDefinition
from .hog_flow import HogFlow
from .hog_functions import HogFunction
@@ -126,6 +127,7 @@ __all__ = [
"Group",
"GroupUsageMetric",
"GroupTypeMapping",
"HeatmapSnapshot",
"HogFlow",
"HogFunction",
"HogFunctionTemplate",
@@ -173,6 +175,7 @@ __all__ = [
"RetentionFilter",
"RemoteConfig",
"EventSchema",
"SavedHeatmap",
"SchemaPropertyGroup",
"SchemaPropertyGroupProperty",
"SessionRecording",

View File

@@ -0,0 +1,80 @@
from django.db import models
from posthog.models.utils import UUIDModel, UUIDTModel
from posthog.utils import generate_short_id
class SavedHeatmap(UUIDTModel):
class Status(models.TextChoices):
PROCESSING = "processing", "Processing"
COMPLETED = "completed", "Completed"
FAILED = "failed", "Failed"
class Type(models.TextChoices):
SCREENSHOT = "screenshot", "Screenshot"
IFRAME = "iframe", "Iframe"
RECORDING = "recording", "Recording"
short_id = models.CharField(max_length=12, blank=True, default=generate_short_id)
name = models.CharField(max_length=400, null=True, blank=True)
team = models.ForeignKey("Team", on_delete=models.CASCADE)
url = models.URLField(max_length=2000)
data_url = models.URLField(max_length=2000, null=True, blank=True, help_text="URL for fetching heatmap data")
# Planned widths to generate for screenshot-type heatmaps
target_widths = models.JSONField(default=list)
type = models.CharField(max_length=20, choices=Type.choices, default=Type.SCREENSHOT)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PROCESSING)
# Content moved to HeatmapSnapshot per width
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey("User", on_delete=models.SET_NULL, null=True, blank=True)
# Error handling
exception = models.TextField(null=True, blank=True)
# Soft delete
deleted = models.BooleanField(default=False)
class Meta:
db_table = "posthog_heatmapsaved"
indexes = [
models.Index(fields=["team", "url"]),
models.Index(fields=["status"]),
models.Index(fields=["deleted"]),
]
constraints = []
unique_together = ("team", "short_id")
@property
def has_content(self) -> bool:
return self.snapshots.filter(
models.Q(content__isnull=False) | models.Q(content_location__isnull=False)
).exists()
def get_analytics_metadata(self) -> dict:
return {
"team_id": self.team_id,
"url": self.url,
"data_url": self.data_url,
"target_widths": self.target_widths,
"type": self.type,
"status": self.status,
}
class HeatmapSnapshot(UUIDModel):
heatmap = models.ForeignKey(SavedHeatmap, on_delete=models.CASCADE, related_name="snapshots")
width = models.IntegerField()
# Content storage (similar to ExportedAsset)
content = models.BinaryField(null=True)
content_location = models.TextField(null=True, blank=True, max_length=1000)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ("heatmap", "width")
indexes = [
models.Index(fields=["heatmap", "width"]),
]

View File

@@ -9,6 +9,7 @@ from . import (
email,
exporter,
feature_flags,
heatmap_screenshot,
hog_functions,
integrations,
plugin_server,
@@ -32,6 +33,7 @@ __all__ = [
"email",
"exporter",
"feature_flags",
"heatmap_screenshot",
"hog_functions",
"integrations",
"plugin_server",

View File

@@ -0,0 +1,231 @@
import structlog
import posthoganalytics
from celery import shared_task
from posthog.exceptions_capture import capture_exception
from posthog.heatmaps.heatmaps_utils import DEFAULT_TARGET_WIDTHS, is_url_allowed, should_block_url
from posthog.models.heatmap_saved import HeatmapSnapshot, SavedHeatmap
from posthog.tasks.exports.image_exporter import HEIGHT_OFFSET
from posthog.tasks.utils import CeleryQueue
from playwright.sync_api import Page, sync_playwright
logger = structlog.get_logger(__name__)
TMP_DIR = "/tmp"
def _dismiss_cookie_banners(page: Page) -> None:
# Try to click obvious accept/allow buttons (generic + Cookiebot)
click_selectors = [
# Generic
'button:has-text("Accept")',
'button:has-text("I Agree")',
'button:has-text("I agree")',
'button:has-text("Got it")',
'button:has-text("OK")',
'button[aria-label*="accept" i]',
'[role="dialog"] button:has-text("Accept")',
'button[id*="accept" i], button[class*="accept" i]',
# OneTrust
"#onetrust-accept-btn-handler",
".onetrust-accept-btn-handler",
# Cookiebot specific
"#CybotCookiebotDialogBodyButtonAccept",
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
]
for sel in click_selectors:
try:
el = page.locator(sel).first
# wait a short time for element to appear, then click
el.wait_for(timeout=500)
el.click(timeout=500)
page.wait_for_timeout(250)
break
except Exception:
pass
# CSS-hide common cookie/consent containers and overlays
css_hide = """
[id*="cookie" i], [class*="cookie" i],
[id*="consent" i], [class*="consent" i],
[id*="gdpr" i], [class*="gdpr" i],
[id*="onetrust" i], [class*="onetrust" i],
[id*="ot-sdk" i], [class*="ot-sdk" i],
[id*="sp_message" i], [class*="sp_message" i],
[id*="sp-consent" i], [class*="sp-consent" i],
[id*="cmp" i], [class*="cmp" i],
[id*="osano" i], [class*="osano" i],
[id*="quantcast" i], [class*="quantcast" i],
iframe[src*="consent" i], iframe[src*="cookie" i], iframe[src*="onetrust" i],
/* generic fixed overlays */
div[style*="position:fixed" i][style*="z-index" i] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
"""
try:
page.add_style_tag(content=css_hide)
except Exception:
pass
# Explicitly remove Cookiebot dialog + underlay if present (add here more specific selectors if needed)
try:
page.evaluate(
"""
() => {
document.getElementById('CybotCookiebotDialog')?.remove();
document.getElementById('CybotCookiebotDialogBodyUnderlay')?.remove();
}
"""
)
except Exception:
pass
def _block_internal_requests(page: Page) -> None:
page.route("**/*", lambda route: route.abort() if should_block_url(route.request.url) else route.continue_())
@shared_task(
ignore_result=True,
queue=CeleryQueue.EXPORTS.value,
autoretry_for=(Exception,),
retry_backoff=2,
retry_backoff_max=60,
max_retries=3,
)
def generate_heatmap_screenshot(screenshot_id: str) -> None:
try:
screenshot = SavedHeatmap.objects.select_related("team", "created_by").get(id=screenshot_id)
except SavedHeatmap.DoesNotExist:
logger.exception("heatmap_screenshot.not_found", screenshot_id=screenshot_id)
return
with posthoganalytics.new_context():
posthoganalytics.tag("team_id", screenshot.team_id)
posthoganalytics.tag("screenshot_id", screenshot.id)
try:
ok, err = is_url_allowed(screenshot.url)
if not ok:
screenshot.status = SavedHeatmap.Status.FAILED
screenshot.exception = f"SSRF blocked: {err}"
screenshot.save(update_fields=["status", "exception"])
logger.warning(
"heatmap_screenshot.ssrf_blocked",
screenshot_id=screenshot.id,
team_id=screenshot.team_id,
url=screenshot.url,
reason=err,
)
return
_generate_screenshots(screenshot)
screenshot.status = SavedHeatmap.Status.COMPLETED
screenshot.save()
logger.info(
"heatmap_screenshot.completed",
screenshot_id=screenshot.id,
team_id=screenshot.team_id,
url=screenshot.url,
)
except Exception as e:
screenshot.status = SavedHeatmap.Status.FAILED
screenshot.exception = str(e)
screenshot.save()
logger.exception(
"heatmap_screenshot.failed",
screenshot_id=screenshot.id,
team_id=screenshot.team_id,
url=screenshot.url,
exception=str(e),
exc_info=True,
)
capture_exception(
e,
additional_properties={
"celery_task": "heatmap_screenshot",
"team_id": screenshot.team_id,
"screenshot_id": screenshot.id,
},
)
raise
def _generate_screenshots(screenshot: SavedHeatmap) -> None:
# Determine target widths
target_widths = screenshot.target_widths or DEFAULT_TARGET_WIDTHS
# Deduplicate and keep order
seen = set()
widths: list[int] = []
for w in target_widths:
if isinstance(w, int) and 100 <= w <= 3000 and w not in seen:
widths.append(w)
seen.add(w)
if not widths:
widths = [1024]
# Collect screenshots in-memory first to avoid Django ORM calls inside Playwright's async context
snapshot_bytes: list[tuple[int, bytes]] = []
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
"--force-device-scale-factor=1",
"--disable-dev-shm-usage",
"--no-sandbox",
"--disable-gpu",
],
)
try:
for w in widths:
ctx = browser.new_context(
viewport={"width": int(w), "height": 800},
device_scale_factor=1, # keep 1:1 CSS px -> bitmap px
is_mobile=(w < 500), # trigger mobile layout on small widths
has_touch=(w < 500), # some sites key on touch capability
user_agent=(
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/115.0.0.0 "
"Mobile/15E148 Safari/604.1"
if w < 500
else None
),
)
page = ctx.new_page()
_block_internal_requests(page)
page.goto(screenshot.url, wait_until="load", timeout=120_000)
_dismiss_cookie_banners(page)
total_height = page.evaluate("""() => Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
)""")
page.set_viewport_size({"width": int(w), "height": int(total_height + HEIGHT_OFFSET)})
page.wait_for_timeout(1000)
image_data: bytes = page.screenshot(full_page=True, type="jpeg", quality=70)
snapshot_bytes.append((w, image_data))
ctx.close()
finally:
browser.close()
# Persist captured images with ORM after Playwright context (back in pure sync context)
for w, image_data in snapshot_bytes:
snapshot, _ = HeatmapSnapshot.objects.get_or_create(heatmap=screenshot, width=w)
snapshot.content = image_data
snapshot.content_location = None
snapshot.save()

View File

@@ -0,0 +1,75 @@
from posthog.test.base import APIBaseTest
from unittest.mock import MagicMock, patch
from posthog.models.heatmap_saved import HeatmapSnapshot, SavedHeatmap
from posthog.tasks.heatmap_screenshot import generate_heatmap_screenshot
class TestHeatmapScreenshotTask(APIBaseTest):
@patch("posthog.tasks.heatmap_screenshot.sync_playwright")
def test_generates_multiple_width_snapshots_and_marks_completed(self, mock_sync_playwright: MagicMock) -> None:
# Arrange Playwright mocks
mock_p = MagicMock()
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
# playwright context manager
mock_sync_playwright.return_value.__enter__.return_value = mock_p
mock_p.chromium.launch.return_value = mock_browser
# context -> page
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
# mock page behavior
mock_page.evaluate.return_value = 1200 # total page height
# Return different bytes per screenshot call to verify width mapping
mock_page.screenshot.side_effect = [b"jpeg320", b"jpeg768", b"jpeg1024"]
heatmap = SavedHeatmap.objects.create(
team=self.team,
url="https://example.com",
created_by=self.user,
target_widths=[320, 768, 1024],
status=SavedHeatmap.Status.PROCESSING,
)
# Act
generate_heatmap_screenshot(heatmap.id)
# Assert status and snapshots
heatmap.refresh_from_db()
assert heatmap.status == SavedHeatmap.Status.COMPLETED
snaps = list(HeatmapSnapshot.objects.filter(heatmap=heatmap).order_by("width"))
assert [s.width for s in snaps] == [320, 768, 1024]
assert snaps[0].content == b"jpeg320"
assert snaps[1].content == b"jpeg768"
assert snaps[2].content == b"jpeg1024"
# Ensure we cleaned up the browser
mock_browser.close.assert_called_once()
@patch("posthog.tasks.heatmap_screenshot.sync_playwright")
def test_failure_marks_failed_and_records_exception(self, mock_sync_playwright: MagicMock) -> None:
# Arrange: make playwright crash when entering context
mock_sync_playwright.return_value.__enter__.side_effect = RuntimeError("boom")
heatmap = SavedHeatmap.objects.create(
team=self.team,
url="https://example.com",
created_by=self.user,
target_widths=[320],
status=SavedHeatmap.Status.PROCESSING,
)
# Act
try:
generate_heatmap_screenshot(heatmap.id)
except RuntimeError:
pass
# Assert
heatmap.refresh_from_db()
assert heatmap.status == SavedHeatmap.Status.FAILED
assert "boom" in (heatmap.exception or "")