diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png index 6c8fc3fd66b54..604993e7b92b0 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--dark.png b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--dark.png index 1c1ff9986edcd..7f98281ae8c0d 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--light.png b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--light.png index 20c57dda22fb3..35b469f93add6 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--light.png and b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty--light.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--dark.png b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--dark.png index 6ce4e1d634f05..90235ec96b132 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--dark.png and b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--light.png b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--light.png index d145281228a70..35cf1a2d25db4 100644 Binary files a/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--light.png and b/frontend/__snapshots__/scenes-other-toolbar--events-debugger-empty-dark--light.png differ diff --git a/frontend/src/toolbar/debug/EventDebugMenu.tsx b/frontend/src/toolbar/debug/EventDebugMenu.tsx index 2346c769f4bbe..78b6f1518aa57 100644 --- a/frontend/src/toolbar/debug/EventDebugMenu.tsx +++ b/frontend/src/toolbar/debug/EventDebugMenu.tsx @@ -2,7 +2,9 @@ import clsx from 'clsx' import { useActions, useValues } from 'kea' import { AnimatedCollapsible } from 'lib/components/AnimatedCollapsible' import { TZLabel } from 'lib/components/TZLabel' -import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch' +import { LemonCheckbox } from 'lib/lemon-ui/LemonCheckbox' +import { LemonInput } from 'lib/lemon-ui/LemonInput' +import { LemonSegmentedButton } from 'lib/lemon-ui/LemonSegmentedButton' import { SimpleKeyValueList } from 'scenes/session-recordings/player/inspector/components/SimpleKeyValueList' import { eventDebugMenuLogic } from '~/toolbar/debug/eventDebugMenuLogic' @@ -10,9 +12,18 @@ import { eventDebugMenuLogic } from '~/toolbar/debug/eventDebugMenuLogic' import { ToolbarMenu } from '../bar/ToolbarMenu' export const EventDebugMenu = (): JSX.Element => { - const { filteredEvents, isCollapsedEventRow, expandedEvent, showRecordingSnapshots, snapshotCount, eventCount } = - useValues(eventDebugMenuLogic) - const { markExpanded, setShowRecordingSnapshots } = useActions(eventDebugMenuLogic) + const { + searchType, + searchText, + filteredEvents, + isCollapsedEventRow, + expandedEvent, + showRecordingSnapshots, + snapshotCount, + eventCount, + filteredProperties, + } = useValues(eventDebugMenuLogic) + const { setSearchType, markExpanded, setShowRecordingSnapshots, setSearchText } = useActions(eventDebugMenuLogic) return ( @@ -22,11 +33,31 @@ export const EventDebugMenu = (): JSX.Element => { Seen {snapshotCount} events. Seen {eventCount} recording snapshots. +
+
+ search: + + + +
+
- setShowRecordingSnapshots(c)} label="Show recording snapshot events" + bordered={true} />
@@ -52,8 +83,15 @@ export const EventDebugMenu = (): JSX.Element => { -
- +
+
@@ -61,7 +99,9 @@ export const EventDebugMenu = (): JSX.Element => { }) ) : (
- Interact with your page and then come back to the toolbar to see what events were generated. + {searchText && searchType === 'events' + ? 'No events match your search.' + : 'Interact with your page and then come back to the toolbar to see what events were generated.'}
)} diff --git a/frontend/src/toolbar/debug/eventDebugMenuLogic.ts b/frontend/src/toolbar/debug/eventDebugMenuLogic.ts index 1b490687c3ad2..4a0373c588f4b 100644 --- a/frontend/src/toolbar/debug/eventDebugMenuLogic.ts +++ b/frontend/src/toolbar/debug/eventDebugMenuLogic.ts @@ -16,8 +16,22 @@ export const eventDebugMenuLogic = kea([ addEvent: (event: EventType) => ({ event }), markExpanded: (id: string | null) => ({ id }), setShowRecordingSnapshots: (show: boolean) => ({ show }), + setSearchText: (searchText: string) => ({ searchText }), + setSearchType: (searchType: 'events' | 'properties') => ({ searchType }), }), reducers({ + searchType: [ + 'events' as 'events' | 'properties', + { + setSearchType: (_, { searchType }) => searchType, + }, + ], + searchText: [ + '', + { + setSearchText: (_, { searchText }) => searchText, + }, + ], events: [ [] as EventType[], { @@ -54,15 +68,38 @@ export const eventDebugMenuLogic = kea([ snapshotCount: [(s) => [s.events], (events) => events.filter((e) => e.event !== '$snapshot').length], eventCount: [(s) => [s.events], (events) => events.filter((e) => e.event === '$snapshot').length], filteredEvents: [ - (s) => [s.showRecordingSnapshots, s.events], - (showRecordingSnapshots, events) => { - return events.filter((e) => { - if (showRecordingSnapshots) { + (s) => [s.showRecordingSnapshots, s.events, s.searchText, s.searchType], + (showRecordingSnapshots, events, searchText, searchType) => { + return events + .filter((e) => { + if (showRecordingSnapshots) { + return true + } else { + return e.event !== '$snapshot' + } + }) + .filter((e) => { + if (searchType === 'events') { + return e.event.includes(searchText) + } return true - } else { - return e.event !== '$snapshot' + }) + }, + ], + filteredProperties: [ + (s) => [s.searchText, s.searchType], + (searchText, searchType) => { + return (p: Record): Record => { + // return a new object with only the properties where key or value match the search text + if (searchType === 'properties') { + return Object.fromEntries( + Object.entries(p).filter(([key, value]) => { + return key.includes(searchText) || (value && value.toString().includes(searchText)) + }) + ) } - }) + return p + } }, ], }), diff --git a/frontend/src/toolbar/elements/heatmapLogic.ts b/frontend/src/toolbar/elements/heatmapLogic.ts index b3e586949f3f3..5047442da1a27 100644 --- a/frontend/src/toolbar/elements/heatmapLogic.ts +++ b/frontend/src/toolbar/elements/heatmapLogic.ts @@ -411,6 +411,13 @@ export const heatmapLogic = kea([ targetFixed: false, y: element.scroll_depth_bucket, }) + } else if ('session_recordings' in element) { + elements.push({ + count: element.session_recordings.length, + xPercentage: element.pointer_relative_x, + targetFixed: element.pointer_target_fixed, + y: element.pointer_y, + }) } else { elements.push({ count: element.count, diff --git a/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx b/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx index 273796a853a9e..60823734705c8 100644 --- a/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx +++ b/frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx @@ -165,7 +165,7 @@ export const HeatmapToolbarMenu = (): JSX.Element => { )} - Aggregation + Show
patchHeatmapFilters({ aggregation: e })} @@ -179,6 +179,10 @@ export const HeatmapToolbarMenu = (): JSX.Element => { value: 'unique_visitors', label: 'Unique visitors', }, + { + value: 'recordings', + label: 'Recordings', + }, ]} size="small" /> diff --git a/frontend/src/toolbar/types.ts b/frontend/src/toolbar/types.ts index 261a9618290a6..20214ffe7ba31 100644 --- a/frontend/src/toolbar/types.ts +++ b/frontend/src/toolbar/types.ts @@ -17,7 +17,7 @@ export type HeatmapRequestType = { url_pattern?: string viewport_width_min?: number viewport_width_max?: number - aggregation: 'total_count' | 'unique_visitors' + aggregation: 'total_count' | 'unique_visitors' | 'recordings' } export type HeatmapResponseType = { @@ -33,6 +33,12 @@ export type HeatmapResponseType = { bucket_count: number cumulative_count: number } + | { + pointer_relative_x: number + pointer_target_fixed: boolean + pointer_y: number + session_recordings: { session_id: string; timestamp: number }[] + } )[] } diff --git a/posthog/heatmaps/heatmaps_api.py b/posthog/heatmaps/heatmaps_api.py index e3fa68b5b4db3..d5acd42c0ddba 100644 --- a/posthog/heatmaps/heatmaps_api.py +++ b/posthog/heatmaps/heatmaps_api.py @@ -23,7 +23,9 @@ distinct_id, pointer_target_fixed, round((x / viewport_width), 2) as pointer_relative_x, - y * scale_factor as client_y + y * scale_factor as client_y, + session_id, + timestamp from heatmaps where {predicates} ) @@ -51,20 +53,12 @@ """ -class HeatmapsRequestSerializer(serializers.Serializer): - viewport_width_min = serializers.IntegerField(required=False) - viewport_width_max = serializers.IntegerField(required=False) +class HeatmapsBaseRequestSerializer(serializers.Serializer): type = serializers.CharField(required=False, default="click") date_from = serializers.CharField(required=False, default="-7d") date_to = serializers.DateField(required=False) url_exact = serializers.CharField(required=False) url_pattern = serializers.CharField(required=False) - aggregation = serializers.ChoiceField( - required=False, - choices=["unique_visitors", "total_count"], - help_text="How to aggregate the response", - default="total_count", - ) def validate_date_from(self, value) -> date: try: @@ -92,6 +86,22 @@ def validate(self, values) -> dict: return values +class HeatmapsExamplesRequestSerializer(HeatmapsBaseRequestSerializer): + x = serializers.IntegerField(required=False) + y = serializers.IntegerField(required=False) + + +class HeatmapsRequestSerializer(HeatmapsBaseRequestSerializer): + viewport_width_min = serializers.IntegerField(required=False) + viewport_width_max = serializers.IntegerField(required=False) + aggregation = serializers.ChoiceField( + required=False, + choices=["unique_visitors", "total_count", "recordings"], + help_text="What is aggregated in the response", + default="total_count", + ) + + class HeatmapResponseItemSerializer(serializers.Serializer): count = serializers.IntegerField(required=True) pointer_y = serializers.IntegerField(required=True) @@ -113,6 +123,22 @@ class HeatmapsScrollDepthResponseSerializer(serializers.Serializer): results = HeatmapScrollDepthResponseItemSerializer(many=True) +class SessionRecordingSerializer(serializers.Serializer): + session_id = serializers.CharField(required=True) + timestamp = serializers.IntegerField(required=True) + + +class HeatmapExampleItemSerializer(serializers.Serializer): + session_recordings = SessionRecordingSerializer(many=True, required=True) + pointer_y = serializers.IntegerField(required=True) + pointer_relative_x = serializers.FloatField(required=True) + pointer_target_fixed = serializers.BooleanField(required=True) + + +class HeatmapsExamplesResponseSerializer(serializers.Serializer): + results = HeatmapExampleItemSerializer(many=True) + + class HeatmapViewSet(TeamAndOrgViewSetMixin, viewsets.GenericViewSet): scope_object = "INTERNAL" @@ -130,26 +156,43 @@ def list(self, request: request.Request, *args: Any, **kwargs: Any) -> response. aggregation = request_serializer.validated_data.pop("aggregation") placeholders: dict[str, Expr] = {k: Constant(value=v) for k, v in request_serializer.validated_data.items()} - is_scrolldepth_query = placeholders.get("type", None) == Constant(value="scrolldepth") - raw_query = SCROLL_DEPTH_QUERY if is_scrolldepth_query else DEFAULT_QUERY + is_scroll_depth_query = placeholders.get("type", None) == Constant(value="scrolldepth") + is_recordings_query = aggregation == "recordings" + + raw_query = SCROLL_DEPTH_QUERY if is_scroll_depth_query else DEFAULT_QUERY - aggregation_count = self._choose_aggregation(aggregation, is_scrolldepth_query) + aggregation_count = self._choose_aggregation(aggregation, is_scroll_depth_query) exprs = self._predicate_expressions(placeholders) stmt = parse_select(raw_query, {"aggregation_count": aggregation_count, "predicates": ast.And(exprs=exprs)}) context = HogQLContext(team_id=self.team.pk, limit_top_select=False) results = execute_hogql_query(query=stmt, team=self.team, limit_context=LimitContext.HEATMAPS, context=context) - if is_scrolldepth_query: + if is_scroll_depth_query: return self._return_scroll_depth_response(results) + elif is_recordings_query: + return self._heatmaps_examples_response(results) else: return self._return_heatmap_coordinates_response(results) - def _choose_aggregation(self, aggregation, is_scrolldepth_query): - aggregation_value = "count(*) as cnt" if aggregation == "total_count" else "count(distinct distinct_id) as cnt" - if is_scrolldepth_query: - aggregation_value = "count(*)" if aggregation == "total_count" else "count(distinct distinct_id)" + @staticmethod + def _choose_aggregation(aggregation: str, is_scroll_depth_query: bool) -> Expr: + point_aggregations = { + "unique_visitors": "count(distinct distinct_id) as cnt", + "total_count": "count(*) as cnt", + "recordings": "groupArray((session_id, toUnixTimestamp(timestamp))) as session_recordings", + } + scroll_aggregations = { + "unique_visitors": "count(distinct distinct_id)", + "total_count": "count(*)", + } + + if is_scroll_depth_query: + aggregation_value = scroll_aggregations[aggregation] + else: + aggregation_value = point_aggregations[aggregation] + aggregation_count = parse_expr(aggregation_value) return aggregation_count @@ -167,6 +210,9 @@ def _predicate_expressions(placeholders: dict[str, Expr]) -> List[ast.Expr]: # "viewport_width_max": "viewport_width <= round({viewport_width_max} / 16)", "url_exact": "current_url = {url_exact}", "url_pattern": "match(current_url, {url_pattern})", + # stored x and y were scaled by the scale factor + "x": "x = round({x} / 16)", + "y": "y = round({y} / 16)", } for predicate_key in placeholders.keys(): @@ -210,6 +256,22 @@ def _return_scroll_depth_response(query_response: HogQLQueryResponse) -> respons response_serializer.is_valid(raise_exception=True) return response.Response(response_serializer.data, status=status.HTTP_200_OK) + @staticmethod + def _heatmaps_examples_response(query_response: HogQLQueryResponse) -> response.Response: + data = [ + { + "pointer_target_fixed": item[0], + "pointer_relative_x": item[1], + "pointer_y": item[2], + "session_recordings": [{"session_id": i[0], "timestamp": i[1]} for i in item[3]], + } + for item in query_response.results or [] + ] + + response_serializer = HeatmapsExamplesResponseSerializer(data={"results": data}) + response_serializer.is_valid(raise_exception=True) + return response.Response(response_serializer.data, status=status.HTTP_200_OK) + class LegacyHeatmapViewSet(HeatmapViewSet): derive_current_team_from_user_only = True diff --git a/posthog/heatmaps/test/__snapshots__/test_heatmaps_api.ambr b/posthog/heatmaps/test/__snapshots__/test_heatmaps_api.ambr index faa7d77c2c79a..1b727de7a26a1 100644 --- a/posthog/heatmaps/test/__snapshots__/test_heatmaps_api.ambr +++ b/posthog/heatmaps/test/__snapshots__/test_heatmaps_api.ambr @@ -10,7 +10,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'rageclick'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), equals(heatmaps.current_url, 'http://example.com'))) GROUP BY pointer_target_fixed, @@ -32,7 +34,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'rageclick'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), equals(heatmaps.current_url, 'http://example.com/about'))) GROUP BY pointer_target_fixed, @@ -54,7 +58,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'rageclick'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), match(heatmaps.current_url, 'http://example.com*'))) GROUP BY pointer_target_fixed, @@ -76,9 +82,11 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps - WHERE and(equals(heatmaps.team_id, 2), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(150, 16))), 0), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(150, 16))), 0))) GROUP BY pointer_target_fixed, pointer_relative_x, client_y @@ -98,9 +106,11 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps - WHERE and(equals(heatmaps.team_id, 2), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(161, 16))), 0), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(161, 16))), 0))) GROUP BY pointer_target_fixed, pointer_relative_x, client_y @@ -120,9 +130,11 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps - WHERE and(equals(heatmaps.team_id, 2), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(177, 16))), 0), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(177, 16))), 0))) GROUP BY pointer_target_fixed, pointer_relative_x, client_y @@ -142,9 +154,11 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps - WHERE and(equals(heatmaps.team_id, 2), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(201, 16))), 0), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(201, 16))), 0))) GROUP BY pointer_target_fixed, pointer_relative_x, client_y @@ -164,9 +178,11 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps - WHERE and(equals(heatmaps.team_id, 2), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(161, 16))), 0), ifNull(lessOrEquals(heatmaps.viewport_width, round(divide(192, 16))), 0), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')), ifNull(greaterOrEquals(heatmaps.viewport_width, round(divide(161, 16))), 0), ifNull(lessOrEquals(heatmaps.viewport_width, round(divide(192, 16))), 0))) GROUP BY pointer_target_fixed, pointer_relative_x, client_y @@ -186,7 +202,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -208,7 +226,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -230,7 +250,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -252,7 +274,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2024-05-03')))) GROUP BY pointer_target_fixed, @@ -263,6 +287,30 @@ allow_experimental_object_type=1 ''' # --- +# name: TestSessionRecordings.test_can_get_example_sessions + ''' + /* user_id:0 request:_snapshot_ */ + SELECT pointer_target_fixed AS pointer_target_fixed, + pointer_relative_x AS pointer_relative_x, + client_y AS client_y, + groupArray(tuple(session_id, toUnixTimestamp(timestamp))) AS session_recordings + FROM + (SELECT heatmaps.distinct_id AS distinct_id, + heatmaps.pointer_target_fixed AS pointer_target_fixed, + round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp + FROM heatmaps + WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-06')))) + GROUP BY pointer_target_fixed, + pointer_relative_x, + client_y + LIMIT 1000000 SETTINGS readonly=2, + max_execution_time=60, + allow_experimental_object_type=1 + ''' +# --- # name: TestSessionRecordings.test_can_get_filter_by_click ''' /* user_id:0 request:_snapshot_ */ @@ -274,7 +322,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -296,7 +346,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'rageclick'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -318,7 +370,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, @@ -340,7 +394,9 @@ (SELECT heatmaps.distinct_id AS distinct_id, heatmaps.pointer_target_fixed AS pointer_target_fixed, round(divide(heatmaps.x, heatmaps.viewport_width), 2) AS pointer_relative_x, - multiply(heatmaps.y, heatmaps.scale_factor) AS client_y + multiply(heatmaps.y, heatmaps.scale_factor) AS client_y, + heatmaps.session_id AS session_id, + toTimeZone(heatmaps.timestamp, 'UTC') AS timestamp FROM heatmaps WHERE and(equals(heatmaps.team_id, 2), equals(heatmaps.type, 'click'), greaterOrEquals(toTimeZone(heatmaps.timestamp, 'UTC'), toDate('2023-03-08')))) GROUP BY pointer_target_fixed, diff --git a/posthog/heatmaps/test/test_heatmaps_api.py b/posthog/heatmaps/test/test_heatmaps_api.py index 18a2c2205d4e6..20ff3708dc60a 100644 --- a/posthog/heatmaps/test/test_heatmaps_api.py +++ b/posthog/heatmaps/test/test_heatmaps_api.py @@ -141,6 +141,32 @@ def test_can_filter_by_exact_url(self) -> None: {"date_from": "2023-03-08", "url_pattern": "http://example.com*", "type": "rageclick"}, 3 ) + @snapshot_clickhouse_queries + def test_can_get_example_sessions(self) -> None: + # type doesn't match + self._create_heatmap_event("session_1", "scrolldepth", "2023-03-08T07:00:00", y=10, x=1000) + + # does match + self._create_heatmap_event("session_2", "click", "2023-03-08T07:00:00", y=10, x=1000) + # scale factor collapses nearby clicks + self._create_heatmap_event("session_3", "click", "2023-03-08T07:00:00", y=9, x=999) + + examples_response = self._get_heatmap({"date_from": "2023-03-06", "type": "click", "aggregation": "recordings"}) + + assert examples_response.json() == { + "results": [ + { + "pointer_relative_x": 10.33, + "pointer_target_fixed": True, + "pointer_y": 16, + "session_recordings": [ + {"session_id": "session_3", "timestamp": 1678258800}, + {"session_id": "session_2", "timestamp": 1678258800}, + ], + }, + ], + } + @snapshot_clickhouse_queries def test_can_get_scrolldepth_counts(self) -> None: # to calculate expected scroll depth bucket from y and viewport height @@ -310,6 +336,7 @@ def test_can_get_count_by_aggregation(self) -> None: [ ["total_count", status.HTTP_200_OK], ["unique_visitors", status.HTTP_200_OK], + ["recordings", status.HTTP_200_OK], ["direction", status.HTTP_400_BAD_REQUEST], # equivalent to not providing it ["", status.HTTP_200_OK],