feat(heatmaps): new heatmaps (#39825)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2
.gitignore
vendored
@@ -122,3 +122,5 @@ bin/mprocs*.local.yaml
|
||||
# Claude Code review outputs
|
||||
CODE_REVIEW.md
|
||||
CODE_DEBUGGING_SESSION.md
|
||||
|
||||
data/seaweedfs
|
||||
BIN
frontend/__snapshots__/scenes-app-heatmap--generating--dark.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/__snapshots__/scenes-app-heatmap--generating--light.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/__snapshots__/scenes-app-heatmap--new--dark.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/__snapshots__/scenes-app-heatmap--new--light.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 30 KiB |
BIN
frontend/__snapshots__/scenes-app-heatmaps-saved--list--dark.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
@@ -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 })
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
@@ -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}
|
||||
46
frontend/src/scenes/heatmaps/components/HeatmapHeader.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
87
frontend/src/scenes/heatmaps/components/HeatmapRecording.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
69
frontend/src/scenes/heatmaps/components/HeatmapsInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/scenes/heatmaps/components/HeatmapsWarnings.tsx
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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) || '{}'
|
||||
@@ -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 = {}
|
||||
123
frontend/src/scenes/heatmaps/scenes/heatmap/HeatmapNewScene.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
141
frontend/src/scenes/heatmaps/scenes/heatmap/HeatmapScene.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
frontend/src/scenes/heatmaps/scenes/heatmap/heatmapLogic.ts
Normal 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()
|
||||
}),
|
||||
])
|
||||
@@ -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: [
|
||||
() => [],
|
||||
@@ -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 = {}
|
||||
177
frontend/src/scenes/heatmaps/scenes/heatmaps/HeatmapsScene.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}),
|
||||
])
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
posthog/heatmaps/heatmaps_utils.py
Normal 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"}
|
||||
81
posthog/heatmaps/test/test_heatmap_screenshots.py
Normal 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)
|
||||
71
posthog/heatmaps/test/test_url_safety.py
Normal 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
|
||||
111
posthog/migrations/0895_savedheatmap_heatmapsnapshot_and_more.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0894_organizationdomain_scim_bearer_token_and_more
|
||||
0895_savedheatmap_heatmapsnapshot_and_more
|
||||
@@ -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",
|
||||
|
||||
80
posthog/models/heatmap_saved.py
Normal 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"]),
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
231
posthog/tasks/heatmap_screenshot.py
Normal 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()
|
||||
75
posthog/tasks/test/test_heatmap_screenshot.py
Normal 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 "")
|
||||