diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5fb6363e57cf..03f469c1af78 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4522,8 +4522,8 @@ const api = { }, recordings: { - async list(params: RecordingsQuery): Promise { - return await new ApiRequest().recordings().withQueryString(toParams(params)).get() + async list(params: RecordingsQuery, options?: ApiMethodOptions): Promise { + return await new ApiRequest().recordings().withQueryString(toParams(params)).get(options) }, async getMatchingEvents(params: string): Promise { return await new ApiRequest().recordingMatchingEvents().withQueryString(params).get() diff --git a/frontend/src/scenes/session-recordings/playlist/Playlist.test.tsx b/frontend/src/scenes/session-recordings/playlist/Playlist.test.tsx index b3f83717d1fa..31eb5117b4c0 100644 --- a/frontend/src/scenes/session-recordings/playlist/Playlist.test.tsx +++ b/frontend/src/scenes/session-recordings/playlist/Playlist.test.tsx @@ -88,4 +88,26 @@ describe('Playlist', () => { }) expect(screen.queryByText(/selected recording/)).not.toBeInTheDocument() }) + + it('recovers from a failed load via the error banner', async () => { + useMocks({ get: { '/api/environments/:team_id/session_recordings': () => [500, { detail: 'boom' }] } }) + logic.actions.loadSessionRecordings() + + renderPlaylist() + + expect((await screen.findAllByText("We couldn't load recordings.")).length).toBeGreaterThan(0) + + useMocks({ + get: { + '/api/environments/:team_id/session_recordings': { results: [], has_next: false }, + }, + }) + userEvent.click(screen.getAllByText('Try again')[0]) + + // A successful reload has to clear the error, even when it legitimately finds nothing. + await waitFor(() => { + expect(screen.queryByText("We couldn't load recordings.")).not.toBeInTheDocument() + }) + expect((await screen.findAllByTestId('mock-troubleshooting')).length).toBeGreaterThan(0) + }) }) diff --git a/frontend/src/scenes/session-recordings/playlist/Playlist.tsx b/frontend/src/scenes/session-recordings/playlist/Playlist.tsx index 2a615e714a9b..85603aae00cb 100644 --- a/frontend/src/scenes/session-recordings/playlist/Playlist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/Playlist.tsx @@ -398,13 +398,32 @@ const TitleWithCount = ({ title, count }: { title?: string; count: number }): JS ) } +const LoadErrorBanner = (): JSX.Element => { + const { sessionRecordingsResponseLoading } = useValues(sessionRecordingsPlaylistLogic) + const { loadSessionRecordings } = useActions(sessionRecordingsPlaylistLogic) + + return ( + loadSessionRecordings(), + }} + > + We couldn't load recordings. + + ) +} + const ListEmptyState = (): JSX.Element => { const { sessionRecordingsAPIErrored, unusableEventsInFilter } = useValues(sessionRecordingsPlaylistLogic) return (
{sessionRecordingsAPIErrored ? ( - Error while trying to load recordings. + ) : unusableEventsInFilter.length ? ( ) : ( @@ -428,7 +447,7 @@ const CollectionEmptyState = ({ return (
{sessionRecordingsAPIErrored ? ( - Error while trying to load recordings. + ) : unusableEventsInFilter.length ? ( ) : isSynthetic ? ( diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index d3a62ccf88e2..9eb020160f00 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -791,7 +791,8 @@ describe('sessionRecordingsPlaylistLogic', () => { expect.objectContaining({ session_ids: ['s1', 's2'] }) ) expect(listSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ session_ids: ['s1', 's2'], date_from: '-7d' }) + expect.objectContaining({ session_ids: ['s1', 's2'], date_from: '-7d' }), + expect.anything() ) }) @@ -817,7 +818,10 @@ describe('sessionRecordingsPlaylistLogic', () => { }).toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) expect(logic.values.filters.session_ids).toBeUndefined() - expect(listSpy).toHaveBeenLastCalledWith(expect.objectContaining({ session_ids: undefined })) + expect(listSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ session_ids: undefined }), + expect.anything() + ) }) }) @@ -923,7 +927,8 @@ describe('sessionRecordingsPlaylistLogic', () => { await expectLogic(logic).toDispatchActions(['loadSessionRecordings', 'loadSessionRecordingsSuccess']) expect(listSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ hide_viewed_recordings: 'current-user' }) + expect.objectContaining({ hide_viewed_recordings: 'current-user' }), + expect.anything() ) }) @@ -934,7 +939,10 @@ describe('sessionRecordingsPlaylistLogic', () => { logic.actions.loadSessionRecordings() await expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']) - expect(listSpy).toHaveBeenLastCalledWith(expect.objectContaining({ hide_viewed_recordings: undefined })) + expect(listSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ hide_viewed_recordings: undefined }), + expect.anything() + ) }) it('bulk delete only marks successfully deleted recordings', async () => { diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 81a2344d6258..987231b6b1ea 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -166,6 +166,8 @@ interface BackendEventsMatching { export type MatchingEventsMatchType = NoEventsToMatch | EventNamesMatching | EventUUIDsMatching | BackendEventsMatching export const RECORDINGS_LIMIT = 20 +/** Well past the slowest healthy list query, so this only fires on a request that has stalled. */ +const RECORDINGS_LIST_TIMEOUT_MS = 90_000 export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... export const defaultRecordingDurationFilter: RecordingDurationFilter = { @@ -990,7 +992,22 @@ export const sessionRecordingsPlaylistLogic = kea { + abortController.abort() + // The abort travels as an AbortError, which is deliberately dropped by the global + // loader error handler, so report it here or the timeout leaves no trace at all. + posthog.captureException(new Error('Timed out loading session recordings')) + }, RECORDINGS_LIST_TIMEOUT_MS) + let response: RecordingsQueryResponse + try { + response = await api.recordings.list(params, { signal: abortController.signal }) + } finally { + clearTimeout(timeout) + } const loadTimeMs = performance.now() - startTime actions.reportRecordingsListFetched(loadTimeMs, values.filters, defaultRecordingDurationFilter) @@ -1167,7 +1184,7 @@ export const sessionRecordingsPlaylistLogic = kea true, - loadSessionRecordingSuccess: () => false, + loadSessionRecordingsSuccess: () => false, setFilters: () => false, setAdvancedFilters: () => false, loadNext: () => false,