Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: session id examples for heatmaps #21845

Closed
wants to merge 12 commits into from
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 48 additions & 8 deletions frontend/src/toolbar/debug/EventDebugMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ 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'

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 (
<ToolbarMenu>
Expand All @@ -22,11 +33,31 @@ export const EventDebugMenu = (): JSX.Element => {
<span className="text-xs">Seen {snapshotCount} events.</span>
<span className="text-xs">Seen {eventCount} recording snapshots.</span>
</div>
<div className="flex justify-center flex-col">
<div className="flex flex-row items-center justify-between space-x-2">
<span>search:</span>
<LemonSegmentedButton
size="small"
value={searchType}
options={[
{
value: 'events',
label: 'events',
},
{ value: 'properties', label: 'properties' },
]}
onChange={setSearchType}
/>

<LemonInput fullWidth={true} type="search" value={searchText} onChange={setSearchText} />
</div>
</div>
<div className="flex justify-center">
<LemonSwitch
<LemonCheckbox
checked={showRecordingSnapshots}
onChange={(c) => setShowRecordingSnapshots(c)}
label="Show recording snapshot events"
bordered={true}
/>
</div>
</div>
Expand All @@ -52,16 +83,25 @@ export const EventDebugMenu = (): JSX.Element => {
<AnimatedCollapsible
collapsed={e.uuid === undefined ? true : isCollapsedEventRow(e.uuid)}
>
<div className="mt-1 ml-1 pl-2 border-l-2">
<SimpleKeyValueList item={e.event === '$snapshot' ? e : e.properties} />
<div className="my-1 ml-1 pl-2 border-l-2">
<SimpleKeyValueList
item={filteredProperties(e.properties)}
emptyMessage={
searchText && searchType === 'properties'
? 'No matching properties'
: 'No properties'
}
/>
</div>
</AnimatedCollapsible>
</div>
)
})
) : (
<div className="px-4 py-2">
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.'}
</div>
)}
</div>
Expand Down
51 changes: 44 additions & 7 deletions frontend/src/toolbar/debug/eventDebugMenuLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,22 @@ export const eventDebugMenuLogic = kea<eventDebugMenuLogicType>([
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[],
{
Expand Down Expand Up @@ -54,15 +68,38 @@ export const eventDebugMenuLogic = kea<eventDebugMenuLogicType>([
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<string, any>): Record<string, any> => {
// 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
}
},
],
}),
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/toolbar/elements/heatmapLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ export const heatmapLogic = kea<heatmapLogicType>([
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,
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/toolbar/stats/HeatmapToolbarMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const HeatmapToolbarMenu = (): JSX.Element => {
</>
)}

<LemonLabel>Aggregation</LemonLabel>
<LemonLabel>Show</LemonLabel>
<div className="flex gap-2 justify-between items-center">
<LemonSegmentedButton
onChange={(e) => patchHeatmapFilters({ aggregation: e })}
Expand All @@ -179,6 +179,10 @@ export const HeatmapToolbarMenu = (): JSX.Element => {
value: 'unique_visitors',
label: 'Unique visitors',
},
{
value: 'recordings',
label: 'Recordings',
},
]}
size="small"
/>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/toolbar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 }[]
}
)[]
}

Expand Down
98 changes: 80 additions & 18 deletions posthog/heatmaps/heatmaps_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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"

Expand All @@ -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

Expand All @@ -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():
Expand Down Expand Up @@ -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
Loading
Loading