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: add oldest sort option to session replays #30848

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
PropertyOperator,
PropertyFilterType,
RecordingPropertyFilter,
RecordingOrder,
Direction,
)
from django.db.models import F, Q
from django.utils import timezone
Expand Down Expand Up @@ -87,7 +89,8 @@
"operator": PropertyOperator.GT,
}
],
"order": "start_time",
"order": RecordingOrder.START_TIME,
"direction": Direction.DESC,
}


Expand Down Expand Up @@ -160,7 +163,8 @@ def convert_legacy_filters_to_universal_filters(filters: Optional[dict[str, Any]
}
],
},
"order": DEFAULT_RECORDING_FILTERS["order"],
"order": RecordingOrder.START_TIME,
"direction": Direction.DESC,
}


Expand Down Expand Up @@ -207,6 +211,7 @@ def convert_filters_to_recordings_query(playlist: SessionRecordingPlaylist) -> R

# Get order and duration filter
order = filters.get("order")
direction = filters.get("direction")
duration_filters = filters.get("duration", [])
if duration_filters and len(duration_filters) > 0:
having_predicates.append(asRecordingPropertyFilter(duration_filters[0]))
Expand Down Expand Up @@ -252,6 +257,7 @@ def convert_filters_to_recordings_query(playlist: SessionRecordingPlaylist) -> R
# Construct the RecordingsQuery
return RecordingsQuery(
order=order,
direction=direction,
date_from=filters.get("date_from"),
date_to=filters.get("date_to"),
properties=properties,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
RecordingOrder,
RecordingPropertyFilter,
RecordingsQuery,
Direction,
)
from posthog.session_recordings.models.session_recording import SessionRecording
from posthog.session_recordings.models.session_recording_playlist import SessionRecordingPlaylist
Expand Down Expand Up @@ -189,6 +190,7 @@ def test_matching_legacy_filters(
},
"filter_test_accounts": False,
"order": RecordingOrder.START_TIME,
"direction": Direction.DESC,
}

assert mock_list_recordings_from_query.call_args[0] == (
Expand All @@ -210,6 +212,7 @@ def test_matching_legacy_filters(
offset=None,
operand=FilterLogicalOperator.AND_,
order=RecordingOrder.START_TIME,
direction=Direction.DESC,
person_uuid=None,
properties=[],
response=None,
Expand Down Expand Up @@ -288,6 +291,7 @@ def test_template_rageclick_filter_should_process(
name="test",
filters={
"order": "start_time",
"direction": "DESC",
"date_to": None,
"duration": [{"key": "active_seconds", "type": "recording", "value": 5, "operator": "gt"}],
"date_from": "-3d",
Expand Down Expand Up @@ -328,6 +332,7 @@ def test_template_rageclick_filter_should_process(
offset=None,
operand=FilterLogicalOperator.AND_,
order=RecordingOrder.START_TIME,
direction=Direction.DESC,
person_uuid=None,
properties=[],
response=None,
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14693,6 +14693,11 @@
"date_to": {
"type": ["string", "null"]
},
"direction": {
"default": "DESC",
"enum": ["ASC", "DESC"],
"type": "string"
},
"distinct_ids": {
"items": {
"type": "string"
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/queries/schema/schema-general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ export interface RecordingsQuery extends DataNode<RecordingsQueryResponse> {
* @default "start_time"
* */
order?: RecordingOrder
/**
* @default "DESC"
* */
direction?: 'ASC' | 'DESC'
limit?: integer
offset?: integer
user_modified_filters?: Record<string, any>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ const SortingKeyToLabel = {
mouse_activity_count: 'Mouse activity',
}

function getLabel(filters: RecordingUniversalFilters): string {
if (filters.order === 'start_time') {
return filters.direction === 'ASC' ? 'Oldest' : 'Latest'
}
return SortingKeyToLabel[filters.order || 'start_time']
}

function SortedBy({
filters,
setFilters,
Expand All @@ -31,9 +38,19 @@ function SortedBy({
highlightWhenActive={false}
items={[
{
label: SortingKeyToLabel['start_time'],
onClick: () => setFilters({ order: 'start_time' }),
active: filters.order === 'start_time',
label: 'Start time',
items: [
{
label: 'Latest',
onClick: () => setFilters({ order: 'start_time', direction: 'DESC' }),
active: filters.order === 'start_time' && filters.direction === 'DESC',
},
{
label: 'Oldest',
onClick: () => setFilters({ order: 'start_time', direction: 'ASC' }),
active: filters.order === 'start_time' && filters.direction === 'ASC',
},
],
},
{
label: SortingKeyToLabel['activity_score'],
Expand Down Expand Up @@ -87,7 +104,7 @@ function SortedBy({
},
]}
icon={<IconSort className="text-lg" />}
label={SortingKeyToLabel[filters.order || 'start_time']}
label={getLabel(filters)}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,18 @@ describe('sessionRecordingsPlaylistLogic', () => {
expect(logic.values.otherRecordings.map((r) => r.console_error_count)).toEqual([100, 50])
})

it('sorts recordings in ascending order', async () => {
await expectLogic(logic, () => {
logic.actions.setFilters({ order: 'console_error_count', direction: 'ASC' })
})
.toDispatchActions(['loadSessionRecordings', 'loadSessionRecordingsSuccess'])
.toMatchValues({
filters: expect.objectContaining({ direction: 'ASC' }),
})

expect(logic.values.otherRecordings.map((r) => r.console_error_count)).toEqual([50, 100])
})

it('adds an offset', async () => {
await expectLogic(logic, () => {
logic.actions.loadSessionRecordings()
Expand Down Expand Up @@ -462,6 +474,7 @@ describe('sessionRecordingsPlaylistLogic', () => {
filters: {
date_from: '2021-10-01',
date_to: '2021-10-10',
direction: 'DESC',
duration: [{ key: 'duration', operator: 'lt', type: 'recording', value: 600 }],
filter_group: {
type: FilterLogicalOperator.And,
Expand All @@ -485,6 +498,7 @@ describe('sessionRecordingsPlaylistLogic', () => {
filters: {
date_from: '2021-10-01',
date_to: '2021-10-10',
direction: 'DESC',
duration: [{ key: 'duration', operator: 'lt', type: 'recording', value: 600 }],
filter_group: {
type: 'AND',
Expand Down Expand Up @@ -525,7 +539,15 @@ describe('sessionRecordingsPlaylistLogic', () => {
filters: {
date_from: '-3d',
date_to: null,
duration: [{ key: 'active_seconds', operator: 'gt', type: 'recording', value: 5 }],
direction: 'DESC',
duration: [
{
key: 'active_seconds',
operator: 'gt',
type: 'recording',
value: 5,
},
],
filter_group: {
type: FilterLogicalOperator.And,
values: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
RecordingUniversalFilters,
SessionRecordingId,
SessionRecordingType,
SortDirection,
} from '~/types'

import { playerSettingsLogic } from '../player/playerSettingsLogic'
Expand Down Expand Up @@ -83,6 +84,7 @@ export const defaultRecordingDurationFilter: RecordingDurationFilter = {
}

export const DEFAULT_RECORDING_FILTERS_ORDER_BY = 'start_time'
export const DEFAULT_RECORDING_FILTERS_DIRECTION = 'DESC'

export const DEFAULT_RECORDING_FILTERS: RecordingUniversalFilters = {
filter_test_accounts: false,
Expand All @@ -91,6 +93,7 @@ export const DEFAULT_RECORDING_FILTERS: RecordingUniversalFilters = {
filter_group: { ...DEFAULT_UNIVERSAL_GROUP_FILTER },
duration: [defaultRecordingDurationFilter],
order: DEFAULT_RECORDING_FILTERS_ORDER_BY,
direction: DEFAULT_RECORDING_FILTERS_DIRECTION,
}

const DEFAULT_PERSON_RECORDING_FILTERS: RecordingUniversalFilters = {
Expand Down Expand Up @@ -289,16 +292,24 @@ function combineLegacyRecordingFilters(

function sortRecordings(
recordings: SessionRecordingType[],
order: RecordingsQuery['order'] | 'duration' = 'start_time'
order: RecordingsQuery['order'] | 'duration' = 'start_time',
direction: SortDirection = 'DESC'
): SessionRecordingType[] {
const orderKey: RecordingOrder = order === 'duration' ? 'recording_duration' : order

return recordings.sort((a, b) => {
const compareRecordings = (a: SessionRecordingType, b: SessionRecordingType): number => {
const orderA = a[orderKey]
const orderB = b[orderKey]
const incomparible = orderA === undefined || orderB === undefined
return incomparible ? 0 : orderA > orderB ? -1 : 1
})

if (orderA === undefined || orderB === undefined) {
return 0
}

const comparison = orderA > orderB ? -1 : 1
return direction === 'DESC' ? comparison : -comparison
}

return recordings.sort(compareRecordings)
}

export interface SessionRecordingPlaylistLogicProps {
Expand Down Expand Up @@ -434,6 +445,7 @@ export const sessionRecordingsPlaylistLogic = kea<sessionRecordingsPlaylistLogic
: response.has_next,
results: response.results,
order: params.order,
direction,
}
},
},
Expand Down Expand Up @@ -492,6 +504,11 @@ export const sessionRecordingsPlaylistLogic = kea<sessionRecordingsPlaylistLogic
})
return getDefaultFilters(props.personUUID)
}

if (filters.order && filters.order !== state.order && !filters.direction) {
filters.direction = DEFAULT_RECORDING_FILTERS_DIRECTION
}

return {
...state,
...filters,
Expand Down Expand Up @@ -766,7 +783,11 @@ export const sessionRecordingsPlaylistLogic = kea<sessionRecordingsPlaylistLogic
return true
})

return sortRecordings(filteredRecordings, filters.order || DEFAULT_RECORDING_FILTERS_ORDER_BY)
return sortRecordings(
filteredRecordings,
filters.order || DEFAULT_RECORDING_FILTERS_ORDER_BY,
filters.direction
)
},
],

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,13 +1154,16 @@ export interface LegacyRecordingFilters {
operand?: FilterLogicalOperator
}

export type SortDirection = 'ASC' | 'DESC'

export interface RecordingUniversalFilters {
date_from?: string | null
date_to?: string | null
duration: RecordingDurationFilter[]
filter_test_accounts?: boolean
filter_group: UniversalFiltersGroup
order?: RecordingsQuery['order']
direction?: SortDirection
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 so this makes the API more powerful than the UI
e.g. I can sort for least active
that's not a terrible thing
but we could copy the DRF/Django approach and accept start_time and -start_time and then pick that out when we construct the query

i'm not actually sure which I prefer

}

export interface UniversalFiltersGroup {
Expand Down
1 change: 1 addition & 0 deletions mypy-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ posthog/queries/funnels/base.py:0: error: Unsupported right operand type for in
posthog/queries/person_query.py:0: error: Incompatible type for lookup 'pk': (got "str | int | list[str]", expected "str | int") [misc]
posthog/queries/trends/trends_actors.py:0: error: Incompatible type for lookup 'pk': (got "str | None", expected "str | int") [misc]
posthog/queries/trends/util.py:0: error: Argument 1 to "translate_hogql" has incompatible type "str | None"; expected "str" [arg-type]
posthog/session_recordings/queries/session_recording_list_from_query.py:0: error: Item "SelectSetQuery" of "SelectQuery | SelectSetQuery" has no attribute "order_by" [union-attr]
posthog/settings/data_stores.py:0: error: Incompatible types in assignment (expression has type "str | None", variable has type "str") [assignment]
posthog/settings/data_stores.py:0: error: Name "DATABASE_URL" already defined on line 0 [no-redef]
posthog/tasks/email.py:0: error: Argument "email" to "add_recipient" of "EmailMessage" has incompatible type "str | None"; expected "str" [arg-type]
Expand Down
6 changes: 6 additions & 0 deletions posthog/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,11 @@ class RecordingPropertyFilter(BaseModel):
value: Optional[Union[str, float, list[Union[str, float]]]] = None


class Direction(StrEnum):
ASC = "ASC"
DESC = "DESC"


class RefreshType(StrEnum):
ASYNC_ = "async"
ASYNC_EXCEPT_ON_CACHE_MISS = "async_except_on_cache_miss"
Expand Down Expand Up @@ -7665,6 +7670,7 @@ class RecordingsQuery(BaseModel):
console_log_filters: Optional[list[LogEntryPropertyFilter]] = None
date_from: Optional[str] = "-3d"
date_to: Optional[str] = None
direction: Optional[Direction] = Direction.DESC
distinct_ids: Optional[list[str]] = None
events: Optional[list[dict[str, Any]]] = None
filter_test_accounts: Optional[bool] = None
Expand Down
Loading