diff --git a/ee/session_recordings/playlist_counters/recordings_that_match_playlist_filters.py b/ee/session_recordings/playlist_counters/recordings_that_match_playlist_filters.py index 99a6c90cbefe2..aab4f8b9f2359 100644 --- a/ee/session_recordings/playlist_counters/recordings_that_match_playlist_filters.py +++ b/ee/session_recordings/playlist_counters/recordings_that_match_playlist_filters.py @@ -144,6 +144,8 @@ def convert_universal_filters_to_recordings_query(universal_filters: dict[str, A expires=TASK_EXPIRATION_TIME, ) def count_recordings_that_match_playlist_filters(playlist_id: int) -> None: + playlist: SessionRecordingPlaylist | None = None + query: RecordingsQuery | None = None try: with REPLAY_PLAYLIST_COUNT_TIMER.time(): playlist = SessionRecordingPlaylist.objects.get(id=playlist_id) @@ -155,6 +157,7 @@ def count_recordings_that_match_playlist_filters(playlist_id: int) -> None: else: existing_value = {} + # if we have results from the last hour we don't need to run the query if existing_value.get("refreshed_at"): last_refreshed_at = datetime.fromisoformat(existing_value["refreshed_at"]) seconds_since_refresh = int((datetime.now() - last_refreshed_at).total_seconds()) @@ -182,13 +185,31 @@ def count_recordings_that_match_playlist_filters(playlist_id: int) -> None: REPLAY_TEAM_PLAYLIST_COUNT_SUCCEEDED.inc() except SessionRecordingPlaylist.DoesNotExist: - logger.info("Playlist does not exist", playlist_id=playlist_id) + logger.info( + "Playlist does not exist", + playlist_id=playlist_id, + playlist_short_id=playlist.short_id if playlist else None, + ) REPLAY_TEAM_PLAYLIST_COUNT_UNKNOWN.inc() except Exception as e: posthoganalytics.capture_exception( - e, properties={"playlist_id": playlist_id, "posthog_feature": "session_replay_playlist_counters"} + e, + properties={ + "playlist_id": playlist_id, + "playlist_short_id": playlist.short_id if playlist else None, + "posthog_feature": "session_replay_playlist_counters", + "filters": playlist.filters if playlist else None, + "query": query, + }, + ) + logger.exception( + "Failed to count recordings that match playlist filters", + playlist_id=playlist_id, + playlist_short_id=playlist.short_id if playlist else None, + filters=playlist.filters if playlist else None, + query=query, + error=e, ) - logger.exception("Failed to count recordings that match playlist filters", playlist_id=playlist_id, error=e) REPLAY_TEAM_PLAYLIST_COUNT_FAILED.inc() diff --git a/frontend/__snapshots__/replay-listings--recordings-play-lists--dark.png b/frontend/__snapshots__/replay-listings--recordings-play-lists--dark.png index 1d8c228976450..891fb95779bac 100644 Binary files a/frontend/__snapshots__/replay-listings--recordings-play-lists--dark.png and b/frontend/__snapshots__/replay-listings--recordings-play-lists--dark.png differ diff --git a/frontend/__snapshots__/replay-listings--recordings-play-lists--light.png b/frontend/__snapshots__/replay-listings--recordings-play-lists--light.png index 4ba80409526e7..e121c17056a00 100644 Binary files a/frontend/__snapshots__/replay-listings--recordings-play-lists--light.png and b/frontend/__snapshots__/replay-listings--recordings-play-lists--light.png differ diff --git a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png index 9953577341b1e..17fb0ea360e40 100644 Binary files a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png and b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png differ diff --git a/frontend/__snapshots__/replay-player-success--recent-recordings--light.png b/frontend/__snapshots__/replay-player-success--recent-recordings--light.png index 6ab0ef8350e95..697d97fdab7e2 100644 Binary files a/frontend/__snapshots__/replay-player-success--recent-recordings--light.png and b/frontend/__snapshots__/replay-player-success--recent-recordings--light.png differ diff --git a/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx b/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx index 7dabc40542905..e047342da6907 100644 --- a/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx +++ b/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx @@ -54,6 +54,18 @@ const playlist = (playlistId: string): SessionRecordingPlaylistType => { email: 'paul@posthog.com', is_email_verified: true, }, + recordings_counts: { + saved_filters: { + count: 10, + watched_count: 4, + has_more: true, + increased: true, + }, + collection: { + count: 10, + watched_count: 5, + }, + }, } } diff --git a/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx b/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx index 6c83abefaf7c1..43b3773311e41 100644 --- a/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx +++ b/frontend/src/scenes/session-recordings/SessionsRecordings-playlist-listing.stories.tsx @@ -1,12 +1,13 @@ import { Meta } from '@storybook/react' import { router } from 'kea-router' +import { FEATURE_FLAGS } from 'lib/constants' import { useEffect } from 'react' import { App } from 'scenes/App' import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' import { recordings } from 'scenes/session-recordings/__mocks__/recordings' import { urls } from 'scenes/urls' -import { mswDecorator } from '~/mocks/browser' +import { mswDecorator, setFeatureFlags } from '~/mocks/browser' import { ReplayTabs } from '~/types' import { recordingPlaylists } from './__mocks__/recording_playlists' @@ -43,6 +44,7 @@ const meta: Meta = { export default meta export function RecordingsPlayLists(): JSX.Element { + setFeatureFlags([FEATURE_FLAGS.SESSION_RECORDINGS_PLAYLIST_COUNT_COLUMN]) useEffect(() => { router.actions.push(urls.replay(ReplayTabs.Playlists)) }, []) diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_playlists.ts b/frontend/src/scenes/session-recordings/__mocks__/recording_playlists.ts index 16f9bc8b0a5d2..a12216077f6aa 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_playlists.ts +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_playlists.ts @@ -44,6 +44,18 @@ export const recordingPlaylists: SavedSessionRecordingPlaylistsResult = { first_name: 'Alex', email: 'alex@posthog.com', }, + recordings_counts: { + saved_filters: { + count: 10, + watched_count: 4, + has_more: true, + increased: true, + }, + collection: { + count: 10, + watched_count: 5, + }, + }, }, { id: 14, @@ -81,6 +93,16 @@ export const recordingPlaylists: SavedSessionRecordingPlaylistsResult = { first_name: 'Alex', email: 'alex@posthog.com', }, + recordings_counts: { + saved_filters: { + count: 3, + watched_count: 4, + }, + collection: { + count: 3, + watched_count: 4, + }, + }, }, { id: 15, diff --git a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx index e9c7cab61cccc..d366df8631ac7 100644 --- a/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx +++ b/frontend/src/scenes/session-recordings/saved-playlists/SavedSessionRecordingPlaylists.tsx @@ -6,6 +6,7 @@ import { DateFilter } from 'lib/components/DateFilter/DateFilter' import { MemberSelect } from 'lib/components/MemberSelect' import { TZLabel } from 'lib/components/TZLabel' import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' +import { IconArrowUp } from 'lib/lemon-ui/icons' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonTableColumn, LemonTableColumns } from 'lib/lemon-ui/LemonTable' import { createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' @@ -39,7 +40,7 @@ function nameColumn(): LemonTableColumn { } function isPlaylistRecordingsCounts(x: unknown): x is PlaylistRecordingsCounts { - return isObject(x) && ('query_count' in x || 'pinned_count' in x) + return isObject(x) && ('collection' in x || 'saved_filters' in x) } export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPlaylistsProps): JSX.Element { @@ -74,39 +75,79 @@ export function SavedSessionRecordingPlaylists({ tab }: SavedSessionRecordingPla return null } - const count = (recordings_counts.pinned_count || 0) + (recordings_counts.query_count || 0) + const hasSavedFiltersCount = recordings_counts.saved_filters?.count !== null + const hasCollectionCount = recordings_counts.collection.count !== null + + const totalPinnedCount = + recordings_counts.collection.count === null ? 'null' : recordings_counts.collection.count + const unwatchedPinnedCount = + (recordings_counts.collection.count || 0) - (recordings_counts.collection.watched_count || 0) + const totalSavedFiltersCount = + recordings_counts.saved_filters?.count === null + ? 'null' + : recordings_counts.saved_filters?.count || 0 + const unwatchedSavedFiltersCount = + (recordings_counts.saved_filters?.count || 0) - + (recordings_counts.saved_filters?.watched_count || 0) + + const totalCount = + (totalPinnedCount === 'null' ? 0 : totalPinnedCount) + + (totalSavedFiltersCount === 'null' ? 0 : totalSavedFiltersCount) + const unwatchedCount = unwatchedPinnedCount + unwatchedSavedFiltersCount const tooltip = (
Playlist counts are recalculated once a day. - {recordings_counts.pinned_count ? ( - Collection has {recordings_counts.pinned_count} pinned recordings - ) : null} - {recordings_counts.query_count ? ( - - Saved filters match {recordings_counts.query_count} - {recordings_counts.has_more && '+'} recordings - - ) : null} + + + + + + + + + + + + + + + + + + + + +
TypeCountUnwatched
Pinned{totalPinnedCount}{unwatchedPinnedCount}
Saved filters + {totalSavedFiltersCount} + {recordings_counts.saved_filters?.has_more ? '+' : ''} + {unwatchedSavedFiltersCount}
) return (
- - {count ? ( + {hasSavedFiltersCount || hasCollectionCount ? ( + - ) : ( + {recordings_counts.saved_filters?.increased ? : null} + + ) : ( + - )} - + + )}
) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 356a95f68d57d..0af2a26b51938 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1433,10 +1433,21 @@ export interface RecordingEventType fullyLoaded: boolean } -export interface PlaylistRecordingsCounts { - query_count?: number - pinned_count?: number +export interface PlaylistCollectionCount { + count: number + watched_count: number +} + +export interface PlaylistSavedFiltersCount { + count: number + watched_count: number has_more?: boolean + increased?: boolean +} + +export interface PlaylistRecordingsCounts { + saved_filters?: PlaylistSavedFiltersCount + collection: PlaylistCollectionCount } export interface SessionRecordingPlaylistType { diff --git a/posthog/helpers/session_recording_playlist_templates.py b/posthog/helpers/session_recording_playlist_templates.py index 38f75531fa5c5..ff00ae0c58544 100644 --- a/posthog/helpers/session_recording_playlist_templates.py +++ b/posthog/helpers/session_recording_playlist_templates.py @@ -104,7 +104,7 @@ "filters": { "order": "start_time", "date_to": "null", - "duration": [{"key": "active_seconds", "type": "recording", "value": 5, "operator": "gt"}], + "duration": [{"key": "duration", "type": "recording", "value": 60, "operator": "gt"}], "date_from": "-3d", "filter_group": {"type": "AND", "values": [{"type": "AND", "values": []}]}, "filter_test_accounts": "true", diff --git a/posthog/session_recordings/session_recording_api.py b/posthog/session_recordings/session_recording_api.py index 9f27c4e834aa7..bac0bd6b2199d 100644 --- a/posthog/session_recordings/session_recording_api.py +++ b/posthog/session_recordings/session_recording_api.py @@ -474,7 +474,7 @@ def retrieve(self, request: request.Request, *args: Any, **kwargs: Any) -> Respo recording.load_person() if not request.user.is_anonymous: - viewed = _current_user_viewed([str(recording.session_id)], cast(User, request.user), self.team) + viewed = current_user_viewed([str(recording.session_id)], cast(User, request.user), self.team) other_viewers = _other_users_viewed([str(recording.session_id)], cast(User, request.user), self.team) recording.viewed = str(recording.session_id) in viewed @@ -1041,7 +1041,7 @@ def list_recordings_from_query( recording_ids_in_list: list[str] = [str(r.session_id) for r in recordings] # Update the viewed status for all loaded recordings with timer("load_viewed_recordings"): - viewed_session_recordings = _current_user_viewed(recording_ids_in_list, user, team) + viewed_session_recordings = current_user_viewed(recording_ids_in_list, user, team) with timer("load_other_viewers_by_recording"): other_viewers = _other_users_viewed(recording_ids_in_list, user, team) @@ -1090,7 +1090,7 @@ def _other_users_viewed(recording_ids_in_list: list[str], user: User | None, tea return other_viewers -def _current_user_viewed(recording_ids_in_list: list[str], user: User | None, team: Team) -> set[str]: +def current_user_viewed(recording_ids_in_list: list[str], user: User | None, team: Team) -> set[str]: if not user: return set() diff --git a/posthog/session_recordings/session_recording_playlist_api.py b/posthog/session_recordings/session_recording_playlist_api.py index dd76836e771ee..624c2de72baa8 100644 --- a/posthog/session_recordings/session_recording_playlist_api.py +++ b/posthog/session_recordings/session_recording_playlist_api.py @@ -36,6 +36,7 @@ from posthog.schema import RecordingsQuery from posthog.session_recordings.models.session_recording_playlist import SessionRecordingPlaylistViewed from posthog.session_recordings.session_recording_api import ( + current_user_viewed, list_recordings_response, query_as_params_to_dict, list_recordings_from_query, @@ -113,23 +114,54 @@ class Meta: created_by = UserBasicSerializer(read_only=True) last_modified_by = UserBasicSerializer(read_only=True) - def get_recordings_counts(self, playlist: SessionRecordingPlaylist) -> dict[str, int | bool | None]: - recordings_counts: dict[str, int | bool | None] = { - "query_count": None, - "pinned_count": None, - "has_more": None, + def get_recordings_counts(self, playlist: SessionRecordingPlaylist) -> dict[str, dict[str, int | bool | None]]: + recordings_counts: dict[str, dict[str, int | bool | None]] = { + "saved_filters": { + "count": None, + "has_more": None, + "watched_count": None, + "increased": None, + }, + "collection": { + "count": None, + "watched_count": None, + }, } try: redis_client = get_client() counts = redis_client.get(f"{PLAYLIST_COUNT_REDIS_PREFIX}{playlist.short_id}") + + user = self.context["request"].user + team = self.context["get_team"]() + if counts: count_data = json.loads(counts) - id_list = count_data.get("session_ids", None) - recordings_counts["query_count"] = len(id_list) if id_list else 0 - recordings_counts["has_more"] = count_data.get("has_more", False) + id_list: list[str] = count_data.get("session_ids", None) + current_count = len(id_list) if id_list else 0 + previous_ids = count_data.get("previous_ids", None) + recordings_counts["saved_filters"] = { + "count": current_count, + "has_more": count_data.get("has_more", False), + "watched_count": len(current_user_viewed(id_list, user, team)) if id_list else 0, + "increased": previous_ids is not None and current_count > len(previous_ids), + } + + playlist_items: QuerySet[SessionRecordingPlaylistItem] = playlist.playlist_items.filter(deleted=False) + watched_playlist_items = current_user_viewed( + # mypy can't detect that it's safe to pass queryset to list() 🤷 + list(playlist.playlist_items.values_list("session_id", flat=True)), # type: ignore + user, + team, + ) + + item_count = playlist_items.count() + watched_count = len(watched_playlist_items) + recordings_counts["collection"] = { + "count": item_count if item_count > 0 else None, + "watched_count": watched_count if watched_count > 0 else None, + } - recordings_counts["pinned_count"] = playlist.playlist_items.count() except Exception as e: posthoganalytics.capture_exception(e) diff --git a/posthog/session_recordings/test/test_session_recording_playlist.py b/posthog/session_recordings/test/test_session_recording_playlist.py index 3b4ec536513bc..6bce4a4fde4e8 100644 --- a/posthog/session_recordings/test/test_session_recording_playlist.py +++ b/posthog/session_recordings/test/test_session_recording_playlist.py @@ -14,6 +14,7 @@ from posthog import redis from posthog.models import SessionRecording, SessionRecordingPlaylistItem, Team from posthog.models.user import User +from posthog.session_recordings.models.session_recording_event import SessionRecordingViewed from posthog.session_recordings.models.session_recording_playlist import ( SessionRecordingPlaylist, SessionRecordingPlaylistViewed, @@ -71,11 +72,20 @@ def test_list_playlists_when_there_are_no_playlists(self): def test_list_playlists_when_there_are_some_playlists(self): playlist_one = self._create_playlist({"name": "test"}) playlist_two = self._create_playlist({"name": "test2"}) + + # set some saved filter counts up + SessionRecordingViewed.objects.create( + team=self.team, + user=self.user, + session_id="a", + ) redis.get_client().set( f"{PLAYLIST_COUNT_REDIS_PREFIX}{playlist_two.json()['short_id']}", - json.dumps({"session_ids": ["a", "b"], "has_more": False}), + json.dumps({"session_ids": ["a", "b"], "has_more": False, "previous_ids": ["b"]}), ) + response = self.client.get(f"/api/projects/{self.team.id}/session_recording_playlists") + assert response.status_code == status.HTTP_200_OK assert response.json() == { "count": 2, @@ -115,9 +125,16 @@ def test_list_playlists_when_there_are_some_playlists(self): "name": "test2", "pinned": False, "recordings_counts": { - "has_more": False, - "pinned_count": 0, - "query_count": 2, + "collection": { + "count": None, + "watched_count": None, + }, + "saved_filters": { + "count": 2, + "has_more": False, + "watched_count": 1, + "increased": True, + }, }, "short_id": playlist_two.json()["short_id"], }, @@ -154,9 +171,16 @@ def test_list_playlists_when_there_are_some_playlists(self): "name": "test", "pinned": False, "recordings_counts": { - "has_more": None, - "pinned_count": 0, - "query_count": None, + "collection": { + "count": None, + "watched_count": None, + }, + "saved_filters": { + "count": None, + "has_more": None, + "watched_count": None, + "increased": None, + }, }, "short_id": playlist_one.json()["short_id"], }, @@ -180,9 +204,16 @@ def test_creates_playlist(self): "last_modified_at": mock.ANY, "last_modified_by": response.json()["last_modified_by"], "recordings_counts": { - "query_count": None, - "pinned_count": 0, - "has_more": None, + "collection": { + "count": None, + "watched_count": None, + }, + "saved_filters": { + "count": None, + "has_more": None, + "watched_count": None, + "increased": None, + }, }, }