From 06cb45bc15e1f7926cbdbf18287e4a4d0eb845a5 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Wed, 11 Oct 2023 14:23:41 +0100 Subject: [PATCH] chore: yeet CH recordings ingestion (#17572) Removing ClickHouse based recordings One big yeet for a man, a great yeet for humanity --- .../test/test_funnel_correlations_persons.py | 58 +- ee/clickhouse/queries/test/test_paths.py | 64 +- ...ickhouse_experiment_secondary_results.ambr | 2 +- .../session_recording_extensions.py | 64 +- .../test/test_session_recording_extensions.py | 127 +- .../test/test_session_recording_playlist.py | 35 +- ...ordings-play-list-no-pinned-recordings.png | Bin 112682 -> 112676 bytes frontend/src/lib/constants.tsx | 4 - frontend/src/lib/utils/eventUsageLogic.ts | 8 +- ...sionsRecordings-player-success.stories.tsx | 23 +- .../__mocks__/recording_snapshots.json | 1321 ----------------- .../__mocks__/recording_snapshots.ts | 29 + .../debug/RecordingDebugInfo.tsx | 39 - .../player/playerMetaLogic.test.ts | 5 +- .../player/sessionRecordingDataLogic.test.ts | 241 +-- .../player/sessionRecordingDataLogic.ts | 128 +- .../sessionRecordingPlayerLogic.test.ts | 27 +- .../player/utils/segmenter.test.ts | 4 +- frontend/src/types.ts | 9 - plugin-server/functional_tests/api.ts | 28 +- .../session-recordings.test.ts | 420 ++---- .../web-performance-events.test.ts | 52 - plugin-server/src/capabilities.ts | 7 - plugin-server/src/config/kafka-topics.ts | 3 - .../session-recordings-consumer-v1.ts | 412 ----- plugin-server/src/main/pluginsServer.ts | 25 - plugin-server/src/types.ts | 11 +- plugin-server/tests/helpers/kafka.ts | 4 +- .../session-recordings-consumer-v1.test.ts | 182 --- posthog/api/capture.py | 21 +- .../api/test/__snapshots__/test_cohort.ambr | 16 +- posthog/api/test/test_capture.py | 99 +- posthog/api/test/test_persons_trends.py | 16 +- posthog/demo/legacy/data_generator.py | 16 +- .../tests/test_session_recording_helpers.py | 762 ---------- .../funnels/test/test_funnel_persons.py | 11 +- .../test/test_funnel_strict_persons.py | 14 +- .../test/test_funnel_trends_persons.py | 29 +- .../test/test_funnel_unordered_persons.py | 11 +- posthog/queries/trends/test/test_person.py | 20 +- posthog/session_recordings/models/metadata.py | 11 +- .../models/session_recording.py | 61 +- .../queries/session_recording_events.py | 87 -- ...sion_recording_list_from_replay_summary.py | 26 +- .../queries/session_recording_properties.py | 5 - .../queries/session_replay_events.py | 36 + .../queries/test/test_session_recording.py | 221 --- ...sion_recording_list_from_session_replay.py | 2 +- .../test/test_session_recording_properties.py | 18 +- .../session_recording_api.py | 109 +- .../session_recording_helpers.py | 206 +-- .../session_recordings/test/test_factory.py | 217 --- .../test/test_lts_session_recordings.py | 64 +- .../test/test_session_recording_helpers.py | 379 +++++ .../test/test_session_recordings.py | 228 +-- posthog/settings/ingestion.py | 13 +- posthog/tasks/test/test_usage_report.py | 58 +- 57 files changed, 1099 insertions(+), 4989 deletions(-) delete mode 100644 frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json create mode 100644 frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts delete mode 100644 frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx delete mode 100644 plugin-server/functional_tests/web-performance-events.test.ts delete mode 100644 plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v1.ts delete mode 100644 plugin-server/tests/main/ingestion-queues/session-recording/session-recordings-consumer-v1.test.ts delete mode 100644 posthog/helpers/tests/test_session_recording_helpers.py delete mode 100644 posthog/session_recordings/queries/session_recording_events.py delete mode 100644 posthog/session_recordings/queries/test/test_session_recording.py delete mode 100644 posthog/session_recordings/test/test_factory.py create mode 100644 posthog/session_recordings/test/test_session_recording_helpers.py diff --git a/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py b/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py index 03a79d7dd7894..81dd5cf6a3920 100644 --- a/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py +++ b/ee/clickhouse/queries/funnels/test/test_funnel_correlations_persons.py @@ -10,7 +10,7 @@ from posthog.constants import INSIGHT_FUNNELS from posthog.models import Cohort, Filter from posthog.models.person import Person -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.tasks.calculate_cohort import insert_cohort_from_insight_filter from posthog.test.base import ( APIBaseTest, @@ -273,13 +273,13 @@ def test_funnel_correlation_on_event_with_recordings(self): event_uuid="21111111-1111-1111-1111-111111111111", ) - create_session_recording_events( - self.team.pk, - datetime(2021, 1, 2, 0, 0, 0), - "user_1", - "s2", - use_recording_table=False, - use_replay_table=True, + timestamp = datetime(2021, 1, 2, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s2", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) # Success filter @@ -371,13 +371,13 @@ def test_funnel_correlation_on_properties_with_recordings(self): event_uuid="21111111-1111-1111-1111-111111111111", ) - create_session_recording_events( - self.team.pk, - datetime(2021, 1, 2, 0, 0, 0), - "user_1", - "s2", - use_recording_table=False, - use_replay_table=True, + timestamp = datetime(2021, 1, 2, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s2", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) # Success filter @@ -444,13 +444,13 @@ def test_strict_funnel_correlation_with_recordings(self): properties={"$session_id": "s2", "$window_id": "w2"}, event_uuid="41111111-1111-1111-1111-111111111111", ) - create_session_recording_events( - self.team.pk, - datetime(2021, 1, 2, 0, 0, 0), - "user_1", - "s2", - use_recording_table=False, - use_replay_table=True, + timestamp = datetime(2021, 1, 2, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s2", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) # Second user with strict funnel drop off, but completed the step events for a normal funnel @@ -479,13 +479,13 @@ def test_strict_funnel_correlation_with_recordings(self): properties={"$session_id": "s3", "$window_id": "w2"}, event_uuid="71111111-1111-1111-1111-111111111111", ) - create_session_recording_events( - self.team.pk, - datetime(2021, 1, 2, 0, 0, 0), - "user_2", - "s3", - use_recording_table=False, - use_replay_table=True, + timestamp1 = datetime(2021, 1, 2, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s3", + distinct_id="user_2", + first_timestamp=timestamp1, + last_timestamp=timestamp1, ) # Success filter diff --git a/ee/clickhouse/queries/test/test_paths.py b/ee/clickhouse/queries/test/test_paths.py index 4a64ebf03c777..03e4203092377 100644 --- a/ee/clickhouse/queries/test/test_paths.py +++ b/ee/clickhouse/queries/test/test_paths.py @@ -19,7 +19,7 @@ from posthog.models.instance_setting import override_instance_config from posthog.queries.paths import Paths, PathsActors from posthog.queries.paths.paths_event_query import PathEventQuery -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -3181,23 +3181,21 @@ def test_recording(self): event_uuid="41111111-1111-1111-1111-111111111111", ), ] - create_session_recording_events( - self.team.pk, - timezone.now(), - "p1", - "s1", - window_id="w1", - use_recording_table=False, - use_replay_table=True, - ) - create_session_recording_events( - self.team.pk, - timezone.now(), - "p1", - "s3", - window_id="w3", - use_recording_table=False, - use_replay_table=True, + timestamp = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="p1", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) + timestamp1 = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s3", + distinct_id="p1", + first_timestamp=timestamp1, + last_timestamp=timestamp1, ) # User with path matches, but no recordings @@ -3328,14 +3326,13 @@ def test_recording_with_start_and_end(self): event_uuid="31111111-1111-1111-1111-111111111111", ), - create_session_recording_events( - self.team.pk, - timezone.now(), - "p1", - "s1", - window_id="w1", - use_recording_table=False, - use_replay_table=True, + timestamp = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="p1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) filter = PathFilter( @@ -3400,14 +3397,13 @@ def test_recording_for_dropoff(self): event_uuid="31111111-1111-1111-1111-111111111111", ), - create_session_recording_events( - self.team.pk, - timezone.now(), - "p1", - "s1", - window_id="w1", - use_recording_table=False, - use_replay_table=True, + timestamp = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="p1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) # No matching events for dropoff diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index d42c1cb3ff2e1..9f9e01f13028a 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,6 +1,6 @@ # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ' - /* user_id:125 celery:posthog.celery.sync_insight_caching_state */ + /* user_id:128 celery:posthog.celery.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/ee/session_recordings/session_recording_extensions.py b/ee/session_recordings/session_recording_extensions.py index cb67977b0c3ba..ebfb3896a1415 100644 --- a/ee/session_recordings/session_recording_extensions.py +++ b/ee/session_recordings/session_recording_extensions.py @@ -10,10 +10,9 @@ from sentry_sdk import capture_exception, capture_message from posthog import settings -from posthog.event_usage import report_team_action from posthog.session_recordings.models.metadata import PersistedRecordingV1 from posthog.session_recordings.models.session_recording import SessionRecording -from posthog.session_recordings.session_recording_helpers import compress_to_string, decompress +from posthog.session_recordings.session_recording_helpers import decompress from posthog.storage import object_storage logger = structlog.get_logger(__name__) @@ -55,13 +54,15 @@ def save_recording_with_new_content(recording: SessionRecording, content: str) - return new_path +class InvalidRecordingForPersisting(Exception): + pass + + def persist_recording(recording_id: str, team_id: int) -> None: """Persist a recording to the S3""" logger.info("Persisting recording: init", recording_id=recording_id, team_id=team_id) - start_time = timezone.now() - if not settings.OBJECT_STORAGE_ENABLED: return @@ -91,10 +92,10 @@ def persist_recording(recording_id: str, team_id: int) -> None: recording.save() return + target_prefix = recording.build_object_storage_path("2023-08-01") + source_prefix = recording.build_blob_ingestion_storage_path() # if snapshots are already in blob storage, then we can just copy the files between buckets with SNAPSHOT_PERSIST_TIME_HISTOGRAM.labels(source="S3").time(): - target_prefix = recording.build_object_storage_path("2023-08-01") - source_prefix = recording.build_blob_ingestion_storage_path() copied_count = object_storage.copy_objects(source_prefix, target_prefix) if copied_count > 0: @@ -104,49 +105,14 @@ def persist_recording(recording_id: str, team_id: int) -> None: logger.info("Persisting recording: done!", recording_id=recording_id, team_id=team_id, source="s3") return else: - # TODO this can be removed when we're happy with the new storage version - with SNAPSHOT_PERSIST_TIME_HISTOGRAM.labels(source="ClickHouse").time(): - recording.load_snapshots(100_000) # TODO: Paginate rather than hardcode a limit - - content: PersistedRecordingV1 = { - "version": "2022-12-22", - "distinct_id": recording.distinct_id, - "snapshot_data_by_window_id": recording.snapshot_data_by_window_id, - } - - string_content = json.dumps(content, default=str) - string_content = compress_to_string(string_content) - - logger.info("Persisting recording: writing to S3...", recording_id=recording_id, team_id=team_id) - - try: - object_path = recording.build_object_storage_path("2022-12-22") - object_storage.write(object_path, string_content.encode("utf-8")) - recording.object_storage_path = object_path - recording.save() - - report_team_action( - recording.team, - "session recording persisted", - {"total_time_ms": (timezone.now() - start_time).total_seconds() * 1000}, - ) - - logger.info( - "Persisting recording: done!", recording_id=recording_id, team_id=team_id, source="ClickHouse" - ) - except object_storage.ObjectStorageError as ose: - capture_exception(ose) - report_team_action( - recording.team, - "session recording persist failed", - {"total_time_ms": (timezone.now() - start_time).total_seconds() * 1000, "error": str(ose)}, - ) - logger.error( - "session_recording.object-storage-error", - recording_id=recording.session_id, - exception=ose, - exc_info=True, - ) + logger.error( + "No snapshots found to copy in S3 when persisting a recording", + recording_id=recording_id, + team_id=team_id, + target_prefix=target_prefix, + source_prefix=source_prefix, + ) + raise InvalidRecordingForPersisting("Could not persist recording: " + recording_id) def load_persisted_recording(recording: SessionRecording) -> Optional[PersistedRecordingV1]: diff --git a/ee/session_recordings/test/test_session_recording_extensions.py b/ee/session_recordings/test/test_session_recording_extensions.py index 86b5b5ba2134d..c71750ed2ab80 100644 --- a/ee/session_recordings/test/test_session_recording_extensions.py +++ b/ee/session_recordings/test/test_session_recording_extensions.py @@ -13,11 +13,9 @@ persist_recording, save_recording_with_new_content, ) +from posthog.models.signals import mute_selected_signals from posthog.session_recordings.models.session_recording import SessionRecording -from posthog.session_recordings.models.session_recording_playlist import SessionRecordingPlaylist -from posthog.session_recordings.models.session_recording_playlist_item import SessionRecordingPlaylistItem from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary -from posthog.session_recordings.test.test_factory import create_session_recording_events from posthog.settings import ( OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_ACCESS_KEY_ID, @@ -46,93 +44,22 @@ def teardown_method(self, method) -> None: bucket = s3.Bucket(OBJECT_STORAGE_BUCKET) bucket.objects.filter(Prefix=TEST_BUCKET).delete() - def create_snapshot(self, session_id, timestamp): - team_id = self.team.pk - - snapshot = { - "timestamp": timestamp.timestamp() * 1000, - "has_full_snapshot": 1, - "type": 2, - "data": {"source": 0, "href": long_url}, - } - - # can't immediately switch playlists to replay table - create_session_recording_events( - team_id=team_id, - distinct_id="distinct_id_1", - timestamp=timestamp, - session_id=session_id, - window_id="window_1", - snapshots=[snapshot], - use_recording_table=True, - use_replay_table=False, - ) - def test_does_not_persist_too_recent_recording(self): recording = SessionRecording.objects.create( team=self.team, session_id=f"test_does_not_persist_too_recent_recording-s1-{uuid4()}" ) - self.create_snapshot(recording.session_id, recording.created_at) - persist_recording(recording.session_id, recording.team_id) - recording.refresh_from_db() - - assert not recording.object_storage_path - - def test_persists_recording_with_original_version_when_not_in_blob_storage(self): - two_minutes_ago = (datetime.now() - timedelta(minutes=2)).replace(tzinfo=timezone.utc) - with freeze_time(two_minutes_ago): - recording = SessionRecording.objects.create( - team=self.team, session_id=f"test_persists_recording-s1-{uuid4()}" - ) - - self.create_snapshot(recording.session_id, recording.created_at - timedelta(hours=48)) - self.create_snapshot(recording.session_id, recording.created_at - timedelta(hours=46)) - - produce_replay_summary( - session_id=recording.session_id, - team_id=self.team.pk, - first_timestamp=(recording.created_at - timedelta(hours=48)).isoformat(), - last_timestamp=(recording.created_at - timedelta(hours=46)).isoformat(), - distinct_id="distinct_id_1", - first_url="https://app.posthog.com/my-url", - ) + produce_replay_summary( + team_id=self.team.pk, + session_id=recording.session_id, + distinct_id="distinct_id_1", + first_timestamp=recording.created_at, + last_timestamp=recording.created_at, + ) persist_recording(recording.session_id, recording.team_id) recording.refresh_from_db() - assert ( - recording.object_storage_path - == f"session_recordings_lts/team-{self.team.pk}/session-{recording.session_id}" - ) - assert recording.start_time == recording.created_at - timedelta(hours=48) - assert recording.end_time == recording.created_at - timedelta(hours=46) - - assert recording.distinct_id == "distinct_id_1" - assert recording.duration == 7200 - assert recording.click_count == 0 - assert recording.keypress_count == 0 - assert recording.start_url == "https://app.posthog.com/my-url" - - assert load_persisted_recording(recording) == { - "version": "2022-12-22", - "distinct_id": "distinct_id_1", - "snapshot_data_by_window_id": { - "window_1": [ - { - "timestamp": (recording.created_at - timedelta(hours=48)).timestamp() * 1000, - "has_full_snapshot": 1, - "type": 2, - "data": {"source": 0, "href": long_url}, - }, - { - "timestamp": (recording.created_at - timedelta(hours=46)).timestamp() * 1000, - "has_full_snapshot": 1, - "type": 2, - "data": {"source": 0, "href": long_url}, - }, - ] - }, - } + assert not recording.object_storage_path def test_can_build_different_object_storage_paths(self) -> None: produce_replay_summary( @@ -205,42 +132,10 @@ def test_persists_recording_from_blob_ingested_storage(self): f"{recording.build_object_storage_path('2023-08-01')}/c", ] - @patch("ee.session_recordings.session_recording_extensions.report_team_action") - def test_persist_tracks_correct_to_posthog(self, mock_capture): - two_minutes_ago = (datetime.now() - timedelta(minutes=2)).replace(tzinfo=timezone.utc) - - with freeze_time(two_minutes_ago): - playlist = SessionRecordingPlaylist.objects.create(team=self.team, name="playlist", created_by=self.user) - recording = SessionRecording.objects.create( - team=self.team, session_id=f"test_persist_tracks_correct_to_posthog-s1-{uuid4()}" - ) - SessionRecordingPlaylistItem.objects.create(playlist=playlist, recording=recording) - - self.create_snapshot(recording.session_id, recording.created_at - timedelta(hours=48)) - self.create_snapshot(recording.session_id, recording.created_at - timedelta(hours=46)) - - produce_replay_summary( - session_id=recording.session_id, - team_id=self.team.pk, - first_timestamp=(recording.created_at - timedelta(hours=48)).isoformat(), - last_timestamp=(recording.created_at - timedelta(hours=46)).isoformat(), - distinct_id="distinct_id_1", - first_url="https://app.posthog.com/my-url", - ) - - persist_recording(recording.session_id, recording.team_id) - - assert mock_capture.call_args_list[0][0][0] == recording.team - assert mock_capture.call_args_list[0][0][1] == "session recording persisted" - - for x in [ - "total_time_ms", - ]: - assert mock_capture.call_args_list[0][0][2][x] > 0 - @patch("ee.session_recordings.session_recording_extensions.object_storage.write") def test_can_save_content_to_new_location(self, mock_write: MagicMock): - with self.settings(OBJECT_STORAGE_SESSION_RECORDING_BLOB_INGESTION_FOLDER=TEST_BUCKET): + # mute selected signals so the post create signal does not try to persist the recording + with self.settings(OBJECT_STORAGE_SESSION_RECORDING_BLOB_INGESTION_FOLDER=TEST_BUCKET), mute_selected_signals(): session_id = f"{uuid4()}" recording = SessionRecording.objects.create( diff --git a/ee/session_recordings/test/test_session_recording_playlist.py b/ee/session_recordings/test/test_session_recording_playlist.py index 6569988518639..0881f47697e99 100644 --- a/ee/session_recordings/test/test_session_recording_playlist.py +++ b/ee/session_recordings/test/test_session_recording_playlist.py @@ -14,7 +14,7 @@ from posthog.models import SessionRecording, SessionRecordingPlaylistItem from posthog.session_recordings.models.session_recording_playlist import SessionRecordingPlaylist from posthog.models.user import User -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.settings import ( OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_ACCESS_KEY_ID, @@ -166,28 +166,30 @@ def test_filters_based_on_params(self): assert len(results) == 1 assert results[0]["short_id"] == playlist3.short_id - def test_get_pinned_recordings_for_playlist(self): + @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects") + def test_get_pinned_recordings_for_playlist(self, mock_copy_objects: MagicMock) -> None: + mock_copy_objects.return_value = 2 + playlist = SessionRecordingPlaylist.objects.create(team=self.team, name="playlist", created_by=self.user) session_one = f"test_fetch_playlist_recordings-session1-{uuid4()}" session_two = f"test_fetch_playlist_recordings-session2-{uuid4()}" three_days_ago = (datetime.now() - timedelta(days=3)).replace(tzinfo=timezone.utc) - # can't immediately switch playlists to replay table - create_session_recording_events( + produce_replay_summary( team_id=self.team.id, - distinct_id="123", - timestamp=three_days_ago, session_id=session_one, - window_id="1234", + distinct_id="123", + first_timestamp=three_days_ago, + last_timestamp=three_days_ago, ) - create_session_recording_events( + produce_replay_summary( team_id=self.team.id, - distinct_id="123", - timestamp=three_days_ago, session_id=session_two, - window_id="1234", + distinct_id="123", + first_timestamp=three_days_ago, + last_timestamp=three_days_ago, ) # Create playlist items @@ -229,14 +231,13 @@ def test_fetch_playlist_recordings(self, mock_copy_objects: MagicMock, mock_list session_two = f"test_fetch_playlist_recordings-session2-{uuid4()}" three_days_ago = (datetime.now() - timedelta(days=3)).replace(tzinfo=timezone.utc) - for id in [session_one, session_two]: - # can't immediately switch playlists to replay table - create_session_recording_events( + for session_id in [session_one, session_two]: + produce_replay_summary( team_id=self.team.id, + session_id=session_id, distinct_id="123", - timestamp=three_days_ago, - session_id=id, - window_id="1234", + first_timestamp=three_days_ago, + last_timestamp=three_days_ago, ) self.client.post( diff --git a/frontend/__snapshots__/scenes-app-recordings--recordings-play-list-no-pinned-recordings.png b/frontend/__snapshots__/scenes-app-recordings--recordings-play-list-no-pinned-recordings.png index b79c4b316f02234f1a8fc4a14278d9a0436b145e..1ec338834227c63ce460ffa6a6fef86d2873f2ec 100644 GIT binary patch literal 112676 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%0A*rt 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 $jM<-Ok*U!1a7_+hB7 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 GfBrA$R{uf( literal 112682 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 {iPQ^^T5^T?Z!upf`ME7W8NZW7ScnQl(h-hv& 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 playerData: SessionPlayerData, durations: RecordingReportLoadTimes, type: SessionRecordingUsageType, - delay?: number, - loadedFromBlobStorage?: boolean - ) => ({ playerData, durations, type, delay, loadedFromBlobStorage }), + delay?: number + ) => ({ playerData, durations, type, delay }), reportHelpButtonViewed: true, reportHelpButtonUsed: (help_type: HelpType) => ({ help_type }), reportRecordingsListFetched: (loadTime: number) => ({ @@ -835,7 +834,7 @@ export const eventUsageLogic = kea({ reportSavedInsightNewInsightClicked: ({ insightType }) => { posthog.capture('saved insights new insight clicked', { insight_type: insightType }) }, - reportRecording: ({ playerData, durations, type, loadedFromBlobStorage }) => { + reportRecording: ({ playerData, durations, type }) => { // @ts-expect-error const eventIndex = new EventIndex(playerData?.snapshots || []) const payload: Partial = { @@ -849,7 +848,6 @@ export const eventUsageLogic = kea({ page_change_events_length: eventIndex.pageChangeEvents().length, recording_width: eventIndex.getRecordingScreenMetadata(0)[0]?.width, load_time: durations.firstPaint?.duration ?? 0, // TODO: DEPRECATED field. Keep around so dashboards don't break - loadedFromBlobStorage, } posthog.capture(`recording ${type}`, payload) }, 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 5feadd782f5b5..501351427ee2a 100644 --- a/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx +++ b/frontend/src/scenes/session-recordings/SessionsRecordings-player-success.stories.tsx @@ -5,7 +5,7 @@ import { mswDecorator } from '~/mocks/browser' import { combineUrl, router } from 'kea-router' import { urls } from 'scenes/urls' import { App } from 'scenes/App' -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' import recording_playlists from './__mocks__/recording_playlists.json' @@ -88,7 +88,26 @@ const meta: Meta = { return [200, { has_next: false, results: response, version: 1 }] }, // without the session-recording-blob-replay feature flag, we only load via ClickHouse - '/api/projects/:team/session_recordings/:id/snapshots': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots': (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) + } + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] + }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, 'api/projects/:team/notebooks': { count: 0, diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json deleted file mode 100644 index cadcbb8a537cd..0000000000000 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json +++ /dev/null @@ -1,1321 +0,0 @@ -{ - "next": null, - "snapshot_data_by_window_id": { - "187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0": [ - { - "type": 4, - "data": { "href": "http://localhost:3000/", "width": 2560, "height": 1304 }, - "timestamp": 1682952380877 - }, - { - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { "type": 1, "name": "html", "publicId": "", "systemId": "", "id": 2 }, - { - "type": 2, - "tagName": "html", - "attributes": { "lang": "en" }, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { "charset": "utf-8" }, - "childNodes": [], - "id": 5 - }, - { - "type": 2, - "tagName": "title", - "attributes": {}, - "childNodes": [{ "type": 3, "textContent": "PostHog", "id": 7 }], - "id": 6 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { - "name": "viewport", - "content": "width=device-width, initial-scale=1" - }, - "childNodes": [], - "id": 8 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "name": "next-head-count", "content": "3" }, - "childNodes": [], - "id": 9 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-n-css": "" }, - "childNodes": [], - "id": 10 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "defer": "", - "nomodule": "", - "src": "http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952380635" - }, - "childNodes": [], - "id": 11 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 12 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/main.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 13 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 14 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 15 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 16 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952380635", - "defer": "" - }, - "childNodes": [], - "id": 17 - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }", - "isStyle": true, - "id": 19 - } - ], - "id": 18 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "id": "__next_css__DO_NOT_USE__" }, - "childNodes": [], - "id": 20 - } - ], - "id": 4 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "__next" }, - "childNodes": [ - { - "type": 2, - "tagName": "main", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "h1", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "PostHog React", - "id": 25 - } - ], - "id": 24 - }, - { - "type": 2, - "tagName": "div", - "attributes": { "class": "buttons" }, - "childNodes": [ - { - "type": 2, - "tagName": "button", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Capture event", - "id": 28 - } - ], - "id": 27 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "data-attr": "autocapture-button" - }, - "childNodes": [ - { - "type": 3, - "textContent": "Autocapture buttons", - "id": 30 - } - ], - "id": 29 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "class": "ph-no-capture", - "rr_width": "155.3046875px", - "rr_height": "21.5px" - }, - "childNodes": [], - "id": 31 - } - ], - "id": 26 - }, - { - "type": 2, - "tagName": "p", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Feature flag response: ", - "id": 33 - }, - { "type": 3, "textContent": "false", "id": 34 } - ], - "id": 32 - } - ], - "id": 23 - } - ], - "id": 22 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "type": "text/javascript", - "src": "http://localhost:8000/static/recorder-v2.js?v=1.53.1" - }, - "childNodes": [], - "id": 35 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952380635" - }, - "childNodes": [], - "id": 36 - }, - { - "type": 2, - "tagName": "script", - "attributes": { "id": "__NEXT_DATA__", "type": "application/json" }, - "childNodes": [ - { "type": 3, "textContent": "SCRIPT_PLACEHOLDER", "id": 38 } - ], - "id": 37 - }, - { - "type": 2, - "tagName": "div", - "attributes": { - "id": "__next-build-watcher", - "style": "position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;" - }, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "container" }, - "childNodes": [ - { "type": 3, "textContent": "\n ", "id": 41 }, - { - "type": 2, - "tagName": "div", - "attributes": { "id": "icon-wrapper" }, - "childNodes": [ - { "type": 3, "textContent": "\n ", "id": 43 }, - { - "type": 2, - "tagName": "svg", - "attributes": { "viewBox": "0 0 226 200" }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 45 - }, - { - "type": 2, - "tagName": "defs", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 47 - }, - { - "type": 2, - "tagName": "lineargradient", - "attributes": { - "x1": "114.720775%", - "y1": "181.283245%", - "x2": "39.5399306%", - "y2": "100%", - "id": "linear-gradient" - }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 49 - }, - { - "type": 2, - "tagName": "stop", - "attributes": { - "stop-color": "#000000", - "offset": "0%" - }, - "childNodes": [], - "isSVG": true, - "id": 50 - }, - { - "type": 3, - "textContent": "\n ", - "id": 51 - }, - { - "type": 2, - "tagName": "stop", - "attributes": { - "stop-color": "#FFFFFF", - "offset": "100%" - }, - "childNodes": [], - "isSVG": true, - "id": 52 - }, - { - "type": 3, - "textContent": "\n ", - "id": 53 - } - ], - "isSVG": true, - "id": 48 - }, - { - "type": 3, - "textContent": "\n ", - "id": 54 - } - ], - "isSVG": true, - "id": 46 - }, - { - "type": 3, - "textContent": "\n ", - "id": 55 - }, - { - "type": 2, - "tagName": "g", - "attributes": { - "id": "icon-group", - "fill": "none", - "stroke": "url(#linear-gradient)", - "stroke-width": "18" - }, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 57 - }, - { - "type": 2, - "tagName": "path", - "attributes": { - "d": "M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z" - }, - "childNodes": [], - "isSVG": true, - "id": 58 - }, - { - "type": 3, - "textContent": "\n ", - "id": 59 - } - ], - "isSVG": true, - "id": 56 - }, - { - "type": 3, - "textContent": "\n ", - "id": 60 - } - ], - "isSVG": true, - "id": 44 - }, - { "type": 3, "textContent": "\n ", "id": 61 } - ], - "id": 42 - }, - { "type": 3, "textContent": "\n ", "id": 62 } - ], - "id": 40, - "isShadow": true - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \n 0% { bottom: 10px; opacity: 0; }\n 100% { bottom: 20px; opacity: 1; }\n}@keyframes strokedash { \n 0% { stroke-dasharray: 0, 226; }\n 80%, 100% { stroke-dasharray: 659, 226; }\n}", - "isStyle": true, - "id": 64 - } - ], - "id": 63, - "isShadow": true - } - ], - "id": 39, - "isShadowHost": true - }, - { - "type": 2, - "tagName": "next-route-announcer", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "p", - "attributes": { - "aria-live": "assertive", - "id": "__next-route-announcer__", - "role": "alert", - "style": "border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;" - }, - "childNodes": [], - "id": 66 - } - ], - "id": 65 - } - ], - "id": 21 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { "left": 0, "top": 0 } - }, - "timestamp": 1682952380882 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 2027, "y": 120, "id": 22, "timeOffset": 0 }] }, - "timestamp": 1682952383040 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 3, "x": 1618.84765625, "y": 299.01953125 }, - "timestamp": 1682952383262 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 3, "x": 1618.84765625, "y": 299.01953125 }, - "timestamp": 1682952383263 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 3, "x": 1618, "y": 299, "pointerType": 0 }, - "timestamp": 1682952383264 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 1618, "y": 299, "id": 3, "timeOffset": -435 }, - { "x": 1609, "y": 296, "id": 3, "timeOffset": -4 } - ] - }, - "timestamp": 1682952383543 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 1239, "y": 216, "id": 23, "timeOffset": -460 }, - { "x": 847, "y": 210, "id": 23, "timeOffset": -409 }, - { "x": 788, "y": 215, "id": 23, "timeOffset": -142 }, - { "x": 754, "y": 163, "id": 32, "timeOffset": -77 }, - { "x": 735, "y": 135, "id": 27, "timeOffset": -25 } - ] - }, - "timestamp": 1682952384050 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.6875 }, - "timestamp": 1682952384230 - }, - { "type": 3, "data": { "source": 2, "type": 5, "id": 27 }, "timestamp": 1682952384231 }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.5546875 }, - "timestamp": 1682952384310 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384313 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384447 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384460 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384463 - }, - { "type": 3, "data": { "source": 2, "type": 4, "id": 27, "x": 729, "y": 124 }, "timestamp": 1682952384464 }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 729, "y": 125, "id": 27, "timeOffset": -466 }, - { "x": 729, "y": 125, "id": 27, "timeOffset": -399 }, - { "x": 729, "y": 124, "id": 27, "timeOffset": -346 }, - { "x": 729, "y": 124, "id": 27, "timeOffset": -231 } - ] - }, - "timestamp": 1682952384555 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384559 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384675 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384676 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384709 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 27, "x": 729.30859375, "y": 124.0546875 }, - "timestamp": 1682952384810 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 27, "x": 729, "y": 124, "pointerType": 0 }, - "timestamp": 1682952384811 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 713, "y": 137, "id": 27, "timeOffset": -49 }] }, - "timestamp": 1682952385058 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 605, "y": 266, "id": 3, "timeOffset": -487 }] }, - "timestamp": 1682952385562 - }, - { "type": 3, "data": { "source": 2, "type": 6, "id": 27 }, "timestamp": 1682952385719 }, - { "type": 3, "data": { "source": 4, "width": 2560, "height": 476 }, "timestamp": 1682952385738 }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 604, "y": 266, "id": 3, "timeOffset": -22 }] }, - "timestamp": 1682952386063 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 453, "y": 173, "id": 22, "timeOffset": -475 }, - { "x": 265, "y": 32, "id": 22, "timeOffset": -418 } - ] - }, - "timestamp": 1682952386571 - } - ], - "187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6": [ - { - "type": 4, - "data": { "href": "http://localhost:3000/", "width": 2560, "height": 1304 }, - "timestamp": 1682952388104 - }, - { - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { "type": 1, "name": "html", "publicId": "", "systemId": "", "id": 2 }, - { - "type": 2, - "tagName": "html", - "attributes": { "lang": "en" }, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "style", - "attributes": { "data-next-hide-fouc": "true" }, - "childNodes": [ - { - "type": 3, - "textContent": "body { display: none; }", - "isStyle": true, - "id": 6 - } - ], - "id": 5 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-next-hide-fouc": "true" }, - "childNodes": [ - { - "type": 3, - "textContent": "", - "id": 8 - } - ], - "id": 7 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "charset": "utf-8" }, - "childNodes": [], - "id": 9 - }, - { - "type": 2, - "tagName": "title", - "attributes": {}, - "childNodes": [{ "type": 3, "textContent": "PostHog", "id": 11 }], - "id": 10 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { - "name": "viewport", - "content": "width=device-width, initial-scale=1" - }, - "childNodes": [], - "id": 12 - }, - { - "type": 2, - "tagName": "meta", - "attributes": { "name": "next-head-count", "content": "3" }, - "childNodes": [], - "id": 13 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "data-n-css": "" }, - "childNodes": [], - "id": 14 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "defer": "", - "nomodule": "", - "src": "http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952387901" - }, - "childNodes": [], - "id": 15 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 16 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/main.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 17 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 18 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 19 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 20 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952387901", - "defer": "" - }, - "childNodes": [], - "id": 21 - }, - { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }", - "isStyle": true, - "id": 23 - } - ], - "id": 22 - }, - { - "type": 2, - "tagName": "noscript", - "attributes": { "id": "__next_css__DO_NOT_USE__" }, - "childNodes": [], - "id": 24 - } - ], - "id": 4 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "div", - "attributes": { "id": "__next" }, - "childNodes": [ - { - "type": 2, - "tagName": "main", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "h1", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "PostHog React", - "id": 29 - } - ], - "id": 28 - }, - { - "type": 2, - "tagName": "div", - "attributes": { "class": "buttons" }, - "childNodes": [ - { - "type": 2, - "tagName": "button", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Capture event", - "id": 32 - } - ], - "id": 31 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "data-attr": "autocapture-button" - }, - "childNodes": [ - { - "type": 3, - "textContent": "Autocapture buttons", - "id": 34 - } - ], - "id": 33 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "class": "ph-no-capture", - "rr_width": "0px", - "rr_height": "0px" - }, - "childNodes": [], - "id": 35 - } - ], - "id": 30 - }, - { - "type": 2, - "tagName": "p", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "Feature flag response: ", - "id": 37 - } - ], - "id": 36 - } - ], - "id": 27 - } - ], - "id": 26 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "type": "text/javascript", - "src": "http://localhost:8000/static/recorder-v2.js?v=1.53.1" - }, - "childNodes": [], - "id": 38 - }, - { - "type": 2, - "tagName": "script", - "attributes": { - "src": "http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952387901" - }, - "childNodes": [], - "id": 39 - }, - { - "type": 2, - "tagName": "script", - "attributes": { "id": "__NEXT_DATA__", "type": "application/json" }, - "childNodes": [ - { "type": 3, "textContent": "SCRIPT_PLACEHOLDER", "id": 41 } - ], - "id": 40 - } - ], - "id": 25 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { "left": 0, "top": 0 } - }, - "timestamp": 1682952388106 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [ - { "parentId": 4, "id": 7 }, - { "parentId": 4, "id": 5 } - ], - "adds": [] - }, - "timestamp": 1682952388108 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [], - "adds": [ - { - "parentId": 25, - "nextId": null, - "node": { - "type": 2, - "tagName": "div", - "attributes": { - "id": "__next-build-watcher", - "style": "position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;" - }, - "childNodes": [], - "id": 42, - "isShadowHost": true - } - }, - { - "parentId": 42, - "nextId": null, - "node": { - "type": 2, - "tagName": "style", - "attributes": {}, - "childNodes": [], - "id": 43, - "isShadow": true - } - }, - { - "parentId": 43, - "nextId": null, - "node": { - "type": 3, - "textContent": "#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \n 0% { bottom: 10px; opacity: 0; }\n 100% { bottom: 20px; opacity: 1; }\n}@keyframes strokedash { \n 0% { stroke-dasharray: 0, 226; }\n 80%, 100% { stroke-dasharray: 659, 226; }\n}", - "isStyle": true, - "id": 44 - } - }, - { - "parentId": 42, - "nextId": 43, - "node": { - "type": 2, - "tagName": "div", - "attributes": { "id": "container" }, - "childNodes": [], - "id": 45, - "isShadow": true - } - }, - { "parentId": 45, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 46 } }, - { - "parentId": 45, - "nextId": 46, - "node": { - "type": 2, - "tagName": "div", - "attributes": { "id": "icon-wrapper" }, - "childNodes": [], - "id": 47 - } - }, - { "parentId": 45, "nextId": 47, "node": { "type": 3, "textContent": "\n ", "id": 48 } }, - { "parentId": 47, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 49 } }, - { - "parentId": 47, - "nextId": 49, - "node": { - "type": 2, - "tagName": "svg", - "attributes": { "viewBox": "0 0 226 200" }, - "childNodes": [], - "isSVG": true, - "id": 50 - } - }, - { "parentId": 47, "nextId": 50, "node": { "type": 3, "textContent": "\n ", "id": 51 } }, - { "parentId": 50, "nextId": null, "node": { "type": 3, "textContent": "\n ", "id": 52 } }, - { - "parentId": 50, - "nextId": 52, - "node": { - "type": 2, - "tagName": "g", - "attributes": { - "id": "icon-group", - "fill": "none", - "stroke": "url(#linear-gradient)", - "stroke-width": "18" - }, - "childNodes": [], - "isSVG": true, - "id": 53 - } - }, - { "parentId": 50, "nextId": 53, "node": { "type": 3, "textContent": "\n ", "id": 54 } }, - { - "parentId": 50, - "nextId": 54, - "node": { - "type": 2, - "tagName": "defs", - "attributes": {}, - "childNodes": [], - "isSVG": true, - "id": 55 - } - }, - { "parentId": 50, "nextId": 55, "node": { "type": 3, "textContent": "\n ", "id": 56 } }, - { - "parentId": 55, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 57 } - }, - { - "parentId": 55, - "nextId": 57, - "node": { - "type": 2, - "tagName": "lineargradient", - "attributes": { - "x1": "114.720775%", - "y1": "181.283245%", - "x2": "39.5399306%", - "y2": "100%", - "id": "linear-gradient" - }, - "childNodes": [], - "isSVG": true, - "id": 58 - } - }, - { - "parentId": 55, - "nextId": 58, - "node": { "type": 3, "textContent": "\n ", "id": 59 } - }, - { - "parentId": 58, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 60 } - }, - { - "parentId": 58, - "nextId": 60, - "node": { - "type": 2, - "tagName": "stop", - "attributes": { "stop-color": "#FFFFFF", "offset": "100%" }, - "childNodes": [], - "isSVG": true, - "id": 61 - } - }, - { - "parentId": 58, - "nextId": 61, - "node": { "type": 3, "textContent": "\n ", "id": 62 } - }, - { - "parentId": 58, - "nextId": 62, - "node": { - "type": 2, - "tagName": "stop", - "attributes": { "stop-color": "#000000", "offset": "0%" }, - "childNodes": [], - "isSVG": true, - "id": 63 - } - }, - { - "parentId": 58, - "nextId": 63, - "node": { "type": 3, "textContent": "\n ", "id": 64 } - }, - { - "parentId": 53, - "nextId": null, - "node": { "type": 3, "textContent": "\n ", "id": 65 } - }, - { - "parentId": 53, - "nextId": 65, - "node": { - "type": 2, - "tagName": "path", - "attributes": { - "d": "M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z" - }, - "childNodes": [], - "isSVG": true, - "id": 66 - } - }, - { "parentId": 53, "nextId": 66, "node": { "type": 3, "textContent": "\n ", "id": 67 } } - ] - }, - "timestamp": 1682952388117 - }, - { - "type": 3, - "data": { - "source": 0, - "texts": [], - "attributes": [], - "removes": [ - { "parentId": 10, "id": 11 }, - { "parentId": 4, "id": 12 } - ], - "adds": [ - { "parentId": 10, "nextId": null, "node": { "type": 3, "textContent": "PostHog", "id": 68 } }, - { - "parentId": 4, - "nextId": 13, - "node": { - "type": 2, - "tagName": "meta", - "attributes": { "name": "viewport", "content": "width=device-width, initial-scale=1" }, - "childNodes": [], - "id": 69 - } - }, - { - "parentId": 25, - "nextId": null, - "node": { - "type": 2, - "tagName": "next-route-announcer", - "attributes": {}, - "childNodes": [], - "id": 70 - } - }, - { - "parentId": 70, - "nextId": null, - "node": { - "type": 2, - "tagName": "p", - "attributes": { - "aria-live": "assertive", - "id": "__next-route-announcer__", - "role": "alert", - "style": "border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;" - }, - "childNodes": [], - "id": 71 - } - }, - { "parentId": 36, "nextId": null, "node": { "type": 3, "textContent": "false", "id": 72 } } - ] - }, - "timestamp": 1682952388132 - }, - { - "type": 3, - "data": { "source": 1, "positions": [{ "x": 294, "y": 7, "id": 26, "timeOffset": 0 }] }, - "timestamp": 1682952388659 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 577, "y": 269, "id": 3, "timeOffset": -438 }, - { "x": 684, "y": 304, "id": 3, "timeOffset": -239 }, - { "x": 762, "y": 244, "id": 3, "timeOffset": -174 }, - { "x": 815, "y": 203, "id": 27, "timeOffset": -123 } - ] - }, - "timestamp": 1682952389163 - }, - { - "type": 3, - "data": { - "source": 1, - "positions": [ - { "x": 819, "y": 197, "id": 27, "timeOffset": -427 }, - { "x": 831, "y": 176, "id": 27, "timeOffset": -362 }, - { "x": 842, "y": 157, "id": 36, "timeOffset": -312 }, - { "x": 850, "y": 142, "id": 27, "timeOffset": -261 }, - { "x": 852, "y": 137, "id": 33, "timeOffset": -176 }, - { "x": 852, "y": 133, "id": 33, "timeOffset": -111 }, - { "x": 852, "y": 133, "id": 33, "timeOffset": -28 } - ] - }, - "timestamp": 1682952389668 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389698 - }, - { "type": 3, "data": { "source": 2, "type": 5, "id": 33 }, "timestamp": 1682952389699 }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389798 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952389798 - }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952389943 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390043 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952390044 - }, - { "type": 3, "data": { "source": 2, "type": 4, "id": 33, "x": 852, "y": 133 }, "timestamp": 1682952390047 }, - { - "type": 3, - "data": { "source": 2, "type": 1, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390112 - }, - { - "type": 3, - "data": { "source": 2, "type": 0, "id": 33, "x": 852.7421875, "y": 133.1640625 }, - "timestamp": 1682952390243 - }, - { - "type": 3, - "data": { "source": 2, "type": 2, "id": 33, "x": 852, "y": 133, "pointerType": 0 }, - "timestamp": 1682952390244 - }, - { "type": 3, "data": { "source": 2, "type": 6, "id": 33 }, "timestamp": 1682952392745 } - ] - }, - "storage": "object_storage" -} diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts new file mode 100644 index 0000000000000..e2e5a8ec6dd59 --- /dev/null +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.ts @@ -0,0 +1,29 @@ +import { RecordingSnapshot } from '~/types' + +const lineOne = + '{"window_id":"187d7c761a0525d-05f175487d4b65-1d525634-384000-187d7c761a149d0","data":[{"type":4,"data":{"href":"http://localhost:3000/","width":2560,"height":1304},"timestamp":1682952380877},{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{"lang":"en"},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":5},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog","id":7}],"id":6},{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":8},{"type":2,"tagName":"meta","attributes":{"name":"next-head-count","content":"3"},"childNodes":[],"id":9},{"type":2,"tagName":"noscript","attributes":{"data-n-css":""},"childNodes":[],"id":10},{"type":2,"tagName":"script","attributes":{"defer":"","nomodule":"","src":"http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952380635"},"childNodes":[],"id":11},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952380635","defer":""},"childNodes":[],"id":12},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/main.js?ts=1682952380635","defer":""},"childNodes":[],"id":13},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952380635","defer":""},"childNodes":[],"id":14},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952380635","defer":""},"childNodes":[],"id":15},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952380635","defer":""},"childNodes":[],"id":16},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952380635","defer":""},"childNodes":[],"id":17},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }","isStyle":true,"id":19}],"id":18},{"type":2,"tagName":"noscript","attributes":{"id":"__next_css__DO_NOT_USE__"},"childNodes":[],"id":20}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"__next"},"childNodes":[{"type":2,"tagName":"main","attributes":{},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog React","id":25}],"id":24},{"type":2,"tagName":"div","attributes":{"class":"buttons"},"childNodes":[{"type":2,"tagName":"button","attributes":{},"childNodes":[{"type":3,"textContent":"Capture event","id":28}],"id":27},{"type":2,"tagName":"button","attributes":{"data-attr":"autocapture-button"},"childNodes":[{"type":3,"textContent":"Autocapture buttons","id":30}],"id":29},{"type":2,"tagName":"button","attributes":{"class":"ph-no-capture","rr_width":"155.3046875px","rr_height":"21.5px"},"childNodes":[],"id":31}],"id":26},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Feature flag response: ","id":33},{"type":3,"textContent":"false","id":34}],"id":32}],"id":23}],"id":22},{"type":2,"tagName":"script","attributes":{"type":"text/javascript","src":"http://localhost:8000/static/recorder-v2.js?v=1.53.1"},"childNodes":[],"id":35},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952380635"},"childNodes":[],"id":36},{"type":2,"tagName":"script","attributes":{"id":"__NEXT_DATA__","type":"application/json"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":38}],"id":37},{"type":2,"tagName":"div","attributes":{"id":"__next-build-watcher","style":"position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;"},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"container"},"childNodes":[{"type":3,"textContent":"\\n ","id":41},{"type":2,"tagName":"div","attributes":{"id":"icon-wrapper"},"childNodes":[{"type":3,"textContent":"\\n ","id":43},{"type":2,"tagName":"svg","attributes":{"viewBox":"0 0 226 200"},"childNodes":[{"type":3,"textContent":"\\n ","id":45},{"type":2,"tagName":"defs","attributes":{},"childNodes":[{"type":3,"textContent":"\\n ","id":47},{"type":2,"tagName":"lineargradient","attributes":{"x1":"114.720775%","y1":"181.283245%","x2":"39.5399306%","y2":"100%","id":"linear-gradient"},"childNodes":[{"type":3,"textContent":"\\n ","id":49},{"type":2,"tagName":"stop","attributes":{"stop-color":"#000000","offset":"0%"},"childNodes":[],"isSVG":true,"id":50},{"type":3,"textContent":"\\n ","id":51},{"type":2,"tagName":"stop","attributes":{"stop-color":"#FFFFFF","offset":"100%"},"childNodes":[],"isSVG":true,"id":52},{"type":3,"textContent":"\\n ","id":53}],"isSVG":true,"id":48},{"type":3,"textContent":"\\n ","id":54}],"isSVG":true,"id":46},{"type":3,"textContent":"\\n ","id":55},{"type":2,"tagName":"g","attributes":{"id":"icon-group","fill":"none","stroke":"url(#linear-gradient)","stroke-width":"18"},"childNodes":[{"type":3,"textContent":"\\n ","id":57},{"type":2,"tagName":"path","attributes":{"d":"M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z"},"childNodes":[],"isSVG":true,"id":58},{"type":3,"textContent":"\\n ","id":59}],"isSVG":true,"id":56},{"type":3,"textContent":"\\n ","id":60}],"isSVG":true,"id":44},{"type":3,"textContent":"\\n ","id":61}],"id":42},{"type":3,"textContent":"\\n ","id":62}],"id":40,"isShadow":true},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \\n 0% { bottom: 10px; opacity: 0; }\\n 100% { bottom: 20px; opacity: 1; }\\n}@keyframes strokedash { \\n 0% { stroke-dasharray: 0, 226; }\\n 80%, 100% { stroke-dasharray: 659, 226; }\\n}","isStyle":true,"id":64}],"id":63,"isShadow":true}],"id":39,"isShadowHost":true},{"type":2,"tagName":"next-route-announcer","attributes":{},"childNodes":[{"type":2,"tagName":"p","attributes":{"aria-live":"assertive","id":"__next-route-announcer__","role":"alert","style":"border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;"},"childNodes":[],"id":66}],"id":65}],"id":21}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1682952380882},{"type":3,"data":{"source":1,"positions":[{"x":2027,"y":120,"id":22,"timeOffset":0}]},"timestamp":1682952383040},{"type":3,"data":{"source":2,"type":1,"id":3,"x":1618.84765625,"y":299.01953125},"timestamp":1682952383262},{"type":3,"data":{"source":2,"type":0,"id":3,"x":1618.84765625,"y":299.01953125},"timestamp":1682952383263},{"type":3,"data":{"source":2,"type":2,"id":3,"x":1618,"y":299,"pointerType":0},"timestamp":1682952383264},{"type":3,"data":{"source":1,"positions":[{"x":1618,"y":299,"id":3,"timeOffset":-435},{"x":1609,"y":296,"id":3,"timeOffset":-4}]},"timestamp":1682952383543},{"type":3,"data":{"source":1,"positions":[{"x":1239,"y":216,"id":23,"timeOffset":-460},{"x":847,"y":210,"id":23,"timeOffset":-409},{"x":788,"y":215,"id":23,"timeOffset":-142},{"x":754,"y":163,"id":32,"timeOffset":-77},{"x":735,"y":135,"id":27,"timeOffset":-25}]},"timestamp":1682952384050},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.6875},"timestamp":1682952384230},{"type":3,"data":{"source":2,"type":5,"id":27},"timestamp":1682952384231},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.5546875},"timestamp":1682952384310},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384313},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384447},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384460},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384463},{"type":3,"data":{"source":2,"type":4,"id":27,"x":729,"y":124},"timestamp":1682952384464},{"type":3,"data":{"source":1,"positions":[{"x":729,"y":125,"id":27,"timeOffset":-466},{"x":729,"y":125,"id":27,"timeOffset":-399},{"x":729,"y":124,"id":27,"timeOffset":-346},{"x":729,"y":124,"id":27,"timeOffset":-231}]},"timestamp":1682952384555},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384559},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384675},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384676},{"type":3,"data":{"source":2,"type":1,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384709},{"type":3,"data":{"source":2,"type":0,"id":27,"x":729.30859375,"y":124.0546875},"timestamp":1682952384810},{"type":3,"data":{"source":2,"type":2,"id":27,"x":729,"y":124,"pointerType":0},"timestamp":1682952384811},{"type":3,"data":{"source":1,"positions":[{"x":713,"y":137,"id":27,"timeOffset":-49}]},"timestamp":1682952385058},{"type":3,"data":{"source":1,"positions":[{"x":605,"y":266,"id":3,"timeOffset":-487}]},"timestamp":1682952385562},{"type":3,"data":{"source":2,"type":6,"id":27},"timestamp":1682952385719},{"type":3,"data":{"source":4,"width":2560,"height":476},"timestamp":1682952385738},{"type":3,"data":{"source":1,"positions":[{"x":604,"y":266,"id":3,"timeOffset":-22}]},"timestamp":1682952386063},{"type":3,"data":{"source":1,"positions":[{"x":453,"y":173,"id":22,"timeOffset":-475},{"x":265,"y":32,"id":22,"timeOffset":-418}]},"timestamp":1682952386571}]}' +const lineTwo = + '{"window_id":"187d7c77dfe1d45-08bdcaf91135a2-1d525634-384000-187d7c77dff39a6","data":[{"type":4,"data":{"href":"http://localhost:3000/","width":2560,"height":1304},"timestamp":1682952388104},{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":1,"name":"html","publicId":"","systemId":"","id":2},{"type":2,"tagName":"html","attributes":{"lang":"en"},"childNodes":[{"type":2,"tagName":"head","attributes":{},"childNodes":[{"type":2,"tagName":"style","attributes":{"data-next-hide-fouc":"true"},"childNodes":[{"type":3,"textContent":"body { display: none; }","isStyle":true,"id":6}],"id":5},{"type":2,"tagName":"noscript","attributes":{"data-next-hide-fouc":"true"},"childNodes":[{"type":3,"textContent":"","id":8}],"id":7},{"type":2,"tagName":"meta","attributes":{"charset":"utf-8"},"childNodes":[],"id":9},{"type":2,"tagName":"title","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog","id":11}],"id":10},{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":12},{"type":2,"tagName":"meta","attributes":{"name":"next-head-count","content":"3"},"childNodes":[],"id":13},{"type":2,"tagName":"noscript","attributes":{"data-n-css":""},"childNodes":[],"id":14},{"type":2,"tagName":"script","attributes":{"defer":"","nomodule":"","src":"http://localhost:3000/_next/static/chunks/polyfills.js?ts=1682952387901"},"childNodes":[],"id":15},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/webpack.js?ts=1682952387901","defer":""},"childNodes":[],"id":16},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/main.js?ts=1682952387901","defer":""},"childNodes":[],"id":17},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/_app.js?ts=1682952387901","defer":""},"childNodes":[],"id":18},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/pages/index.js?ts=1682952387901","defer":""},"childNodes":[],"id":19},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_buildManifest.js?ts=1682952387901","defer":""},"childNodes":[],"id":20},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/development/_ssgManifest.js?ts=1682952387901","defer":""},"childNodes":[],"id":21},{"type":2,"tagName":"style","attributes":{},"childNodes":[{"type":3,"textContent":"main { margin: 0px auto; max-width: 1200px; padding: 2rem; font-family: helvetica, arial, sans-serif; }.buttons { display: flex; gap: 0.5rem; }","isStyle":true,"id":23}],"id":22},{"type":2,"tagName":"noscript","attributes":{"id":"__next_css__DO_NOT_USE__"},"childNodes":[],"id":24}],"id":4},{"type":2,"tagName":"body","attributes":{},"childNodes":[{"type":2,"tagName":"div","attributes":{"id":"__next"},"childNodes":[{"type":2,"tagName":"main","attributes":{},"childNodes":[{"type":2,"tagName":"h1","attributes":{},"childNodes":[{"type":3,"textContent":"PostHog React","id":29}],"id":28},{"type":2,"tagName":"div","attributes":{"class":"buttons"},"childNodes":[{"type":2,"tagName":"button","attributes":{},"childNodes":[{"type":3,"textContent":"Capture event","id":32}],"id":31},{"type":2,"tagName":"button","attributes":{"data-attr":"autocapture-button"},"childNodes":[{"type":3,"textContent":"Autocapture buttons","id":34}],"id":33},{"type":2,"tagName":"button","attributes":{"class":"ph-no-capture","rr_width":"0px","rr_height":"0px"},"childNodes":[],"id":35}],"id":30},{"type":2,"tagName":"p","attributes":{},"childNodes":[{"type":3,"textContent":"Feature flag response: ","id":37}],"id":36}],"id":27}],"id":26},{"type":2,"tagName":"script","attributes":{"type":"text/javascript","src":"http://localhost:8000/static/recorder-v2.js?v=1.53.1"},"childNodes":[],"id":38},{"type":2,"tagName":"script","attributes":{"src":"http://localhost:3000/_next/static/chunks/react-refresh.js?ts=1682952387901"},"childNodes":[],"id":39},{"type":2,"tagName":"script","attributes":{"id":"__NEXT_DATA__","type":"application/json"},"childNodes":[{"type":3,"textContent":"SCRIPT_PLACEHOLDER","id":41}],"id":40}],"id":25}],"id":3}],"id":1},"initialOffset":{"left":0,"top":0}},"timestamp":1682952388106},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":4,"id":7},{"parentId":4,"id":5}],"adds":[]},"timestamp":1682952388108},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[],"adds":[{"parentId":25,"nextId":null,"node":{"type":2,"tagName":"div","attributes":{"id":"__next-build-watcher","style":"position: fixed; bottom: 10px; right: 20px; width: 0px; height: 0px; z-index: 99999;"},"childNodes":[],"id":42,"isShadowHost":true}},{"parentId":42,"nextId":null,"node":{"type":2,"tagName":"style","attributes":{},"childNodes":[],"id":43,"isShadow":true}},{"parentId":43,"nextId":null,"node":{"type":3,"textContent":"#container { position: absolute; bottom: 10px; right: 30px; border-radius: 3px; background: rgb(0, 0, 0); color: rgb(255, 255, 255); font: initial; cursor: initial; letter-spacing: initial; text-shadow: initial; text-transform: initial; visibility: initial; padding: 7px 10px 8px; align-items: center; box-shadow: rgba(0, 0, 0, 0.25) 0px 11px 40px 0px, rgba(0, 0, 0, 0.12) 0px 2px 10px 0px; display: none; opacity: 0; transition: opacity 0.1s ease 0s, bottom 0.1s ease 0s; animation: 0.1s ease-in-out 0s 1 normal none running fade-in; }#container.visible { display: flex; }#container.building { bottom: 20px; opacity: 1; }#icon-wrapper { width: 16px; height: 16px; }#icon-wrapper > svg { width: 100%; height: 100%; }#icon-group { animation: 1s ease-in-out 0s infinite normal both running strokedash; }@keyframes fade-in { \\n 0% { bottom: 10px; opacity: 0; }\\n 100% { bottom: 20px; opacity: 1; }\\n}@keyframes strokedash { \\n 0% { stroke-dasharray: 0, 226; }\\n 80%, 100% { stroke-dasharray: 659, 226; }\\n}","isStyle":true,"id":44}},{"parentId":42,"nextId":43,"node":{"type":2,"tagName":"div","attributes":{"id":"container"},"childNodes":[],"id":45,"isShadow":true}},{"parentId":45,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":46}},{"parentId":45,"nextId":46,"node":{"type":2,"tagName":"div","attributes":{"id":"icon-wrapper"},"childNodes":[],"id":47}},{"parentId":45,"nextId":47,"node":{"type":3,"textContent":"\\n ","id":48}},{"parentId":47,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":49}},{"parentId":47,"nextId":49,"node":{"type":2,"tagName":"svg","attributes":{"viewBox":"0 0 226 200"},"childNodes":[],"isSVG":true,"id":50}},{"parentId":47,"nextId":50,"node":{"type":3,"textContent":"\\n ","id":51}},{"parentId":50,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":52}},{"parentId":50,"nextId":52,"node":{"type":2,"tagName":"g","attributes":{"id":"icon-group","fill":"none","stroke":"url(#linear-gradient)","stroke-width":"18"},"childNodes":[],"isSVG":true,"id":53}},{"parentId":50,"nextId":53,"node":{"type":3,"textContent":"\\n ","id":54}},{"parentId":50,"nextId":54,"node":{"type":2,"tagName":"defs","attributes":{},"childNodes":[],"isSVG":true,"id":55}},{"parentId":50,"nextId":55,"node":{"type":3,"textContent":"\\n ","id":56}},{"parentId":55,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":57}},{"parentId":55,"nextId":57,"node":{"type":2,"tagName":"lineargradient","attributes":{"x1":"114.720775%","y1":"181.283245%","x2":"39.5399306%","y2":"100%","id":"linear-gradient"},"childNodes":[],"isSVG":true,"id":58}},{"parentId":55,"nextId":58,"node":{"type":3,"textContent":"\\n ","id":59}},{"parentId":58,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":60}},{"parentId":58,"nextId":60,"node":{"type":2,"tagName":"stop","attributes":{"stop-color":"#FFFFFF","offset":"100%"},"childNodes":[],"isSVG":true,"id":61}},{"parentId":58,"nextId":61,"node":{"type":3,"textContent":"\\n ","id":62}},{"parentId":58,"nextId":62,"node":{"type":2,"tagName":"stop","attributes":{"stop-color":"#000000","offset":"0%"},"childNodes":[],"isSVG":true,"id":63}},{"parentId":58,"nextId":63,"node":{"type":3,"textContent":"\\n ","id":64}},{"parentId":53,"nextId":null,"node":{"type":3,"textContent":"\\n ","id":65}},{"parentId":53,"nextId":65,"node":{"type":2,"tagName":"path","attributes":{"d":"M113,5.08219117 L4.28393801,197.5 L221.716062,197.5 L113,5.08219117 Z"},"childNodes":[],"isSVG":true,"id":66}},{"parentId":53,"nextId":66,"node":{"type":3,"textContent":"\\n ","id":67}}]},"timestamp":1682952388117},{"type":3,"data":{"source":0,"texts":[],"attributes":[],"removes":[{"parentId":10,"id":11},{"parentId":4,"id":12}],"adds":[{"parentId":10,"nextId":null,"node":{"type":3,"textContent":"PostHog","id":68}},{"parentId":4,"nextId":13,"node":{"type":2,"tagName":"meta","attributes":{"name":"viewport","content":"width=device-width, initial-scale=1"},"childNodes":[],"id":69}},{"parentId":25,"nextId":null,"node":{"type":2,"tagName":"next-route-announcer","attributes":{},"childNodes":[],"id":70}},{"parentId":70,"nextId":null,"node":{"type":2,"tagName":"p","attributes":{"aria-live":"assertive","id":"__next-route-announcer__","role":"alert","style":"border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap; overflow-wrap: normal;"},"childNodes":[],"id":71}},{"parentId":36,"nextId":null,"node":{"type":3,"textContent":"false","id":72}}]},"timestamp":1682952388132},{"type":3,"data":{"source":1,"positions":[{"x":294,"y":7,"id":26,"timeOffset":0}]},"timestamp":1682952388659},{"type":3,"data":{"source":1,"positions":[{"x":577,"y":269,"id":3,"timeOffset":-438},{"x":684,"y":304,"id":3,"timeOffset":-239},{"x":762,"y":244,"id":3,"timeOffset":-174},{"x":815,"y":203,"id":27,"timeOffset":-123}]},"timestamp":1682952389163},{"type":3,"data":{"source":1,"positions":[{"x":819,"y":197,"id":27,"timeOffset":-427},{"x":831,"y":176,"id":27,"timeOffset":-362},{"x":842,"y":157,"id":36,"timeOffset":-312},{"x":850,"y":142,"id":27,"timeOffset":-261},{"x":852,"y":137,"id":33,"timeOffset":-176},{"x":852,"y":133,"id":33,"timeOffset":-111},{"x":852,"y":133,"id":33,"timeOffset":-28}]},"timestamp":1682952389668},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389698},{"type":3,"data":{"source":2,"type":5,"id":33},"timestamp":1682952389699},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389798},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952389798},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952389943},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390043},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952390044},{"type":3,"data":{"source":2,"type":4,"id":33,"x":852,"y":133},"timestamp":1682952390047},{"type":3,"data":{"source":2,"type":1,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390112},{"type":3,"data":{"source":2,"type":0,"id":33,"x":852.7421875,"y":133.1640625},"timestamp":1682952390243},{"type":3,"data":{"source":2,"type":2,"id":33,"x":852,"y":133,"pointerType":0},"timestamp":1682952390244},{"type":3,"data":{"source":2,"type":6,"id":33},"timestamp":1682952392745}]}' + +export const snapshotsAsJSONLines = (): string => `${lineOne}\n${lineTwo}\n` + +export const sortedRecordingSnapshots = (): { snapshot_data_by_window_id: Record } => { + const sortedRecordingSnapshotsJson = { snapshot_data_by_window_id: {} } + + snapshotsAsJSONLines() + .trim() + .split('\n') + .forEach((line) => { + const j = JSON.parse(line) + sortedRecordingSnapshotsJson.snapshot_data_by_window_id[j.window_id] = j.data + .map((jd: Record) => { + return { + windowId: j.window_id, + ...jd, + } + }) + .sort((a: any, b: any) => a.timestamp - b.timestamp) + }) + + return sortedRecordingSnapshotsJson +} diff --git a/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx b/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx deleted file mode 100644 index cc099a5617eda..0000000000000 --- a/frontend/src/scenes/session-recordings/debug/RecordingDebugInfo.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import clsx from 'clsx' -import { useValues } from 'kea' -import { IconInfo } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { SessionRecordingType } from '~/types' - -export function RecordingDebugInfo({ - recording, - className, -}: { - recording: SessionRecordingType - className?: string -}): JSX.Element | null { - const { featureFlags } = useValues(featureFlagLogic) - const debugMode = !!featureFlags[FEATURE_FLAGS.RECORDING_DEBUGGING] - - if (!debugMode) { - return null - } - - return ( - -

  • - ID: {recording.id} -
  • -
  • - Storage: {recording.storage} -
  • - - } - > - - - ) -} diff --git a/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts b/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts index ccad721c9e713..f6c4b38c3f2b7 100644 --- a/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/playerMetaLogic.test.ts @@ -5,7 +5,7 @@ import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/se import { playerMetaLogic } from 'scenes/session-recordings/player/playerMetaLogic' import recordingMetaJson from '../__mocks__/recording_meta.json' import recordingEventsJson from '../__mocks__/recording_events_query' -import recordingSnapshotsJson from '../__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from '../__mocks__/recording_snapshots' import { useMocks } from '~/mocks/jest' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -18,7 +18,8 @@ describe('playerMetaLogic', () => { useMocks({ get: { '/api/projects/:team/session_recordings/:id': recordingMetaJson, - '/api/projects/:team/session_recordings/:id/snapshots/': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots/': (_, res, ctx) => + res(ctx.text(snapshotsAsJSONLines())), }, post: { '/api/projects/:team/query': recordingEventsJson, diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts index 69ed720f2e524..2e0c2cb130a3b 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -7,7 +7,6 @@ import { api, MOCK_TEAM_ID } from 'lib/api.mock' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import recordingSnapshotsJson from '../__mocks__/recording_snapshots.json' import recordingMetaJson from '../__mocks__/recording_meta.json' import recordingEventsJson from '../__mocks__/recording_events_query' import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' @@ -17,20 +16,9 @@ import { userLogic } from 'scenes/userLogic' import { AvailableFeature } from '~/types' import { useAvailableFeatures } from '~/mocks/features' -const createSnapshotEndpoint = (id: number): string => `api/projects/${MOCK_TEAM_ID}/session_recordings/${id}/snapshots` -const EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX = new RegExp( - `api/projects/${MOCK_TEAM_ID}/session_recordings/\\d/snapshots` -) +import { snapshotsAsJSONLines, sortedRecordingSnapshots } from '../__mocks__/recording_snapshots' -const sortedRecordingSnapshotsJson = { - snapshot_data_by_window_id: {}, -} - -Object.keys(recordingSnapshotsJson.snapshot_data_by_window_id).forEach((key) => { - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[key] = [ - ...recordingSnapshotsJson.snapshot_data_by_window_id[key], - ].sort((a, b) => a.timestamp - b.timestamp) -}) +const sortedRecordingSnapshotsJson = sortedRecordingSnapshots() describe('sessionRecordingDataLogic', () => { let logic: ReturnType @@ -39,24 +27,25 @@ describe('sessionRecordingDataLogic', () => { useAvailableFeatures([AvailableFeature.RECORDINGS_PERFORMANCE]) useMocks({ get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.params.id === 'forced_upgrade') { - // the API will 302 to the version 2 endpoint, which (in production) fetch auto-follows - return [ - 200, - { - sources: [ - { - source: 'blob', - start_timestamp: '2023-08-11T12:03:36.097000Z', - end_timestamp: '2023-08-11T12:04:52.268000Z', - blob_key: '1691755416097-1691755492268', - }, - ], - }, - ] + '/api/projects/:team/session_recordings/:id/snapshots': async (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) } - return [200, recordingSnapshotsJson] + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, }, @@ -86,7 +75,6 @@ describe('sessionRecordingDataLogic', () => { segments: [], sessionEventsData: null, filters: {}, - chunkPaginationIndex: 0, sessionEventsDataLoading: false, }) }) @@ -101,7 +89,8 @@ describe('sessionRecordingDataLogic', () => { .toDispatchActions(['loadRecordingMetaSuccess', 'loadRecordingSnapshotsSuccess']) .toFinishAllListeners() - expect(logic.values.sessionPlayerData).toMatchObject({ + const actual = logic.values.sessionPlayerData + expect(actual).toMatchObject({ person: recordingMetaJson.person, bufferedToTime: 11868, snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, @@ -222,189 +211,14 @@ describe('sessionRecordingDataLogic', () => { }) }) - describe('force upgrade of session recording snapshots endpoint', () => { - it('can force upgrade by returning 302', async () => { - logic = sessionRecordingDataLogic({ sessionRecordingId: 'forced_upgrade' }) - logic.mount() - // Most of these tests assume the metadata is being loaded upfront which is the typical case - logic.actions.loadRecordingMeta() - - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }) - .toDispatchActions([ - 'loadRecordingSnapshotsV1Success', - 'loadRecordingSnapshotsV2', - 'loadRecordingSnapshotsV2Success', - ]) - .toMatchValues({ - sessionPlayerSnapshotData: { - snapshots: [], - sources: [ - { - loaded: true, - source: 'blob', - start_timestamp: '2023-08-11T12:03:36.097000Z', - end_timestamp: '2023-08-11T12:04:52.268000Z', - blob_key: '1691755416097-1691755492268', - }, - ], - }, - }) - }) - }) - - describe('loading session snapshots', () => { - beforeEach(async () => { - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - }) - - it('no next url', async () => { - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }) - .toDispatchActions(['loadRecordingSnapshots', 'loadRecordingSnapshotsSuccess']) - .toNotHaveDispatchedActions(['loadRecordingSnapshots']) - .toFinishAllListeners() - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - durationMs: 11868, - snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, - }, - sessionPlayerSnapshotData: { - next: null, - }, - }) - }) - - it('fetch all chunks of recording', async () => { - const snapshots1 = { snapshot_data_by_window_id: {} } - const snapshots2 = { snapshot_data_by_window_id: {} } - - Object.keys(sortedRecordingSnapshotsJson.snapshot_data_by_window_id).forEach((windowId) => { - snapshots1.snapshot_data_by_window_id[windowId] = - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[windowId].slice(0, 3) - snapshots2.snapshot_data_by_window_id[windowId] = - sortedRecordingSnapshotsJson.snapshot_data_by_window_id[windowId].slice(3) - }) - - const snapshotUrl = createSnapshotEndpoint(3) - const firstNext = `${snapshotUrl}/?offset=200&limit=200` - let nthSnapshotCall = 0 - logic.unmount() - useAvailableFeatures([]) - useMocks({ - get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.url.pathname.match(EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX)) { - const payload = { - ...(nthSnapshotCall === 0 ? snapshots1 : snapshots2), - next: nthSnapshotCall === 0 ? firstNext : undefined, - } - nthSnapshotCall += 1 - return [200, payload] - } - }, - }, - }) - - logic.mount() - logic.actions.loadRecordingMeta() - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - api.get.mockClear() - logic.actions.loadRecordingSnapshots() - await expectLogic(logic).toMount([eventUsageLogic]).toFinishAllListeners() - await expectLogic(logic).toDispatchActions(['loadRecordingSnapshotsV1', 'loadRecordingSnapshotsV1Success']) - - await expectLogic(logic) - .toDispatchActions([ - logic.actionCreators.loadRecordingSnapshotsV1(firstNext), - 'loadRecordingSnapshotsV1Success', - ]) - .toFinishAllListeners() - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - durationMs: 11868, - }, - sessionPlayerSnapshotData: { - next: undefined, - }, - }) - expect(api.get).toBeCalledTimes(2) // 2 calls to loadRecordingSnapshots - }) - - it('server error mid-way through recording', async () => { - let nthSnapshotCall = 0 - logic.unmount() - useAvailableFeatures([]) - useMocks({ - get: { - '/api/projects/:team/session_recordings/:id/snapshots': (req) => { - if (req.url.pathname.match(EVENTS_SESSION_RECORDING_SNAPSHOTS_ENDPOINT_REGEX)) { - if (nthSnapshotCall === 0) { - const payload = { - ...recordingSnapshotsJson, - next: firstNext, - } - nthSnapshotCall += 1 - return [200, payload] - } else { - throw new Error('Error in second request') - } - } - }, - }, - }) - logic.mount() - logic.actions.loadRecordingMeta() - - await expectLogic(logic).toDispatchActions(['loadRecordingMetaSuccess']) - await expectLogic(logic).toMount([eventUsageLogic]).toFinishAllListeners() - api.get.mockClear() - - const snapshotUrl = createSnapshotEndpoint(1) - const firstNext = `${snapshotUrl}/?offset=200&limit=200` - silenceKeaLoadersErrors() - - await expectLogic(logic, () => { - logic.actions.loadRecordingSnapshots() - }).toDispatchActions(['loadRecordingSnapshotsV1', 'loadRecordingSnapshotsV1Success']) - - expect(logic.values).toMatchObject({ - sessionPlayerData: { - person: recordingMetaJson.person, - bufferedToTime: 11868, - snapshotsByWindowId: sortedRecordingSnapshotsJson.snapshot_data_by_window_id, - }, - sessionPlayerSnapshotData: { - next: firstNext, - }, - }) - await expectLogic(logic) - .toDispatchActions([ - logic.actionCreators.loadRecordingSnapshotsV1(firstNext), - 'loadRecordingSnapshotsV1Failure', - ]) - .toFinishAllListeners() - resumeKeaLoadersErrors() - expect(api.get).toHaveBeenCalledWith(firstNext) - }) - }) - describe('report usage', () => { it('send `recording loaded` event only when entire recording has loaded', async () => { await expectLogic(logic, () => { logic.actions.loadRecordingSnapshots() }) .toDispatchActionsInAnyOrder([ - 'loadRecordingSnapshotsV1', - 'loadRecordingSnapshotsV1Success', + 'loadRecordingSnapshotsV2', + 'loadRecordingSnapshotsV2Success', 'loadEvents', 'loadEventsSuccess', ]) @@ -420,15 +234,12 @@ describe('sessionRecordingDataLogic', () => { eventUsageLogic.actionTypes.reportRecording, // viewed eventUsageLogic.actionTypes.reportRecording, // analyzed ]) - .toMatchValues({ - chunkPaginationIndex: 1, - }) }) }) describe('prepareRecordingSnapshots', () => { it('should remove duplicate snapshots and sort by timestamp', () => { - const snapshots = convertSnapshotsByWindowId(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsByWindowId(sortedRecordingSnapshotsJson.snapshot_data_by_window_id) const snapshotsWithDuplicates = snapshots .slice(0, 2) .concat(snapshots.slice(0, 2)) @@ -440,7 +251,7 @@ describe('sessionRecordingDataLogic', () => { }) it('should match snapshot', () => { - const snapshots = convertSnapshotsByWindowId(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsByWindowId(sortedRecordingSnapshotsJson.snapshot_data_by_window_id) expect(prepareRecordingSnapshots(snapshots)).toMatchSnapshot() }) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index 3ea0ead150f1a..e989e3a8bf863 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -12,7 +12,6 @@ import { SessionPlayerData, SessionPlayerSnapshotData, SessionRecordingId, - SessionRecordingSnapshotResponse, SessionRecordingSnapshotSource, SessionRecordingType, SessionRecordingUsageType, @@ -26,16 +25,13 @@ import { userLogic } from 'scenes/userLogic' import { chainToElements } from 'lib/utils/elements-chain' import { captureException } from '@sentry/react' import { createSegments, mapSnapshotsToWindowId } from './utils/segmenter' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { FEATURE_FLAGS } from 'lib/constants' import posthog from 'posthog-js' -import { getCurrentExporterData } from '~/exporter/exporterViewLogic' const IS_TEST_MODE = process.env.NODE_ENV === 'test' const BUFFER_MS = 60000 // +- before and after start and end of a recording to query for. -const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[]): RecordingSnapshot[] => { - const snapshots: RecordingSnapshot[] = items.flatMap((l) => { +const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[]): RecordingSnapshot[] => + items.flatMap((l) => { try { const snapshotLine = typeof l === 'string' ? (JSON.parse(l) as EncodedRecordingSnapshot) : l const snapshotData = snapshotLine['data'] @@ -50,9 +46,6 @@ const parseEncodedSnapshots = (items: (EncodedRecordingSnapshot | string)[]): Re } }) - return snapshots -} - const getHrefFromSnapshot = (snapshot: RecordingSnapshot): string | undefined => { return (snapshot.data as any)?.href || (snapshot.data as any)?.payload?.href } @@ -142,7 +135,7 @@ export const sessionRecordingDataLogic = kea([ key(({ sessionRecordingId }) => sessionRecordingId || 'no-session-recording-id'), connect({ logic: [eventUsageLogic], - values: [teamLogic, ['currentTeamId'], userLogic, ['hasAvailableFeature'], featureFlagLogic, ['featureFlags']], + values: [teamLogic, ['currentTeamId'], userLogic, ['hasAvailableFeature']], }), defaults({ sessionPlayerMetaData: null as SessionRecordingType | null, @@ -151,7 +144,6 @@ export const sessionRecordingDataLogic = kea([ setFilters: (filters: Partial) => ({ filters }), loadRecordingMeta: true, maybeLoadRecordingMeta: true, - loadRecordingSnapshotsV1: (nextUrl?: string) => ({ nextUrl }), loadRecordingSnapshotsV2: (source?: SessionRecordingSnapshotSource) => ({ source }), loadRecordingSnapshots: true, loadRecordingSnapshotsSuccess: true, @@ -170,18 +162,6 @@ export const sessionRecordingDataLogic = kea([ setFilters: (state, { filters }) => ({ ...state, ...filters }), }, ], - chunkPaginationIndex: [ - 0, - { - loadRecordingSnapshotsSuccess: (state) => state + 1, - }, - ], - loadedFromBlobStorage: [ - false as boolean, - { - loadRecordingSnapshotsV2Success: () => true, - }, - ], isNotFound: [ false as boolean, { @@ -209,30 +189,18 @@ export const sessionRecordingDataLogic = kea([ return } if (!values.sessionPlayerSnapshotData?.snapshots) { - // if `getCurrentExporterData` has a value then we're embedded/exported - // so, we always want to use blob replay - if (values.featureFlags[FEATURE_FLAGS.SESSION_RECORDING_BLOB_REPLAY] || getCurrentExporterData()) { - actions.loadRecordingSnapshotsV2() - } else { - actions.loadRecordingSnapshotsV1() - } + actions.loadRecordingSnapshotsV2() } actions.loadEvents() }, loadRecordingSnapshotsV2Success: () => { const { snapshots, sources } = values.sessionPlayerSnapshotData ?? {} if (snapshots && !snapshots.length && sources?.length === 1) { - const canFallbackToClickHouse = values.canFallbackToClickHouseForData - // We got the snapshot response for realtime, and it was empty, so we fall back to the old API - // Until we migrate over we need to fall back to the old API if the new one returns no snapshots + // We got only a snapshot response for realtime, and it was empty posthog.capture('recording_snapshots_v2_empty_response', { source: sources[0], - canFallbackToClickHouse, }) - if (canFallbackToClickHouse) { - actions.loadRecordingSnapshotsV1() - } return } @@ -244,35 +212,15 @@ export const sessionRecordingDataLogic = kea([ actions.loadRecordingSnapshotsV2(nextSourceToLoad) } }, - loadRecordingSnapshotsV1Success: ({ sessionPlayerSnapshotData }) => { - if (sessionPlayerSnapshotData?.sources?.length) { - // v1 request was force-upgraded to v2 - actions.loadRecordingSnapshotsV2Success(sessionPlayerSnapshotData, undefined) - return + loadRecordingSnapshotsSuccess: () => { + cache.firstPaintDurationRow = { + size: (values.sessionPlayerSnapshotData?.snapshots ?? []).length, + duration: Math.round(performance.now() - cache.snapshotsStartTime), } - actions.loadRecordingSnapshotsSuccess() - - if (values.sessionPlayerSnapshotData?.next) { - actions.loadRecordingSnapshotsV1(values.sessionPlayerSnapshotData?.next) - } - if (values.chunkPaginationIndex === 1 || values.loadedFromBlobStorage) { - // Not always accurate that recording is playable after first chunk is loaded, but good guesstimate for now - // when loading from blob storage by the time this is hit the chunkPaginationIndex is already > 1 - // when loading from the API the chunkPaginationIndex is 1 for the first success that reaches this point - cache.firstPaintDurationRow = { - size: (values.sessionPlayerSnapshotData?.snapshots ?? []).length, - duration: Math.round(performance.now() - cache.snapshotsStartTime), - } - } - }, - loadRecordingSnapshotsSuccess: () => { actions.reportViewed() actions.reportUsageIfFullyLoaded() }, - loadRecordingSnapshotsV1Failure: () => { - actions.loadRecordingSnapshotsFailure() - }, loadRecordingSnapshotsV2Failure: () => { actions.loadRecordingSnapshotsFailure() }, @@ -303,16 +251,14 @@ export const sessionRecordingDataLogic = kea([ values.sessionPlayerData, durations, SessionRecordingUsageType.VIEWED, - 0, - values.loadedFromBlobStorage + 0 ) await breakpoint(IS_TEST_MODE ? 1 : 10000) eventUsageLogic.actions.reportRecording( values.sessionPlayerData, durations, SessionRecordingUsageType.ANALYZED, - 10, - values.loadedFromBlobStorage + 10 ) }, @@ -357,52 +303,6 @@ export const sessionRecordingDataLogic = kea([ sessionPlayerSnapshotData: [ null as SessionPlayerSnapshotData | null, { - loadRecordingSnapshotsV1: async ( - { nextUrl }, - breakpoint - ): Promise => { - cache.snapshotsStartTime = performance.now() - - if (!props.sessionRecordingId) { - return values.sessionPlayerSnapshotData - } - await breakpoint(1) - - const apiUrl = - nextUrl || - `api/projects/${values.currentTeamId}/session_recordings/${props.sessionRecordingId}/snapshots` - - const response: SessionRecordingSnapshotResponse = await api.get(apiUrl) - breakpoint() - - if (response.snapshot_data_by_window_id) { - // NOTE: This might seem backwards as we translate the snapshotsByWindowId to an array and then derive it again later but - // this is for future support of the API that will return them as a simple array - const snapshots = convertSnapshotsResponse( - response.snapshot_data_by_window_id, - nextUrl ? values.sessionPlayerSnapshotData?.snapshots ?? [] : [] - ) - - posthog.capture('recording_snapshot_loaded', { - source: 'clickhouse', - }) - - return { - snapshots, - next: response.next, - } - } else if (response.sources) { - // we've been force-upgraded to V2 by 302 redirect - const data: SessionPlayerSnapshotData = { - ...(values.sessionPlayerSnapshotData || {}), - } - data.sources = response.sources - return data - } else { - throw new Error('Invalid response from snapshots API') - } - }, - loadRecordingSnapshotsV2: async ({ source }, breakpoint): Promise => { if (!props.sessionRecordingId) { return values.sessionPlayerSnapshotData @@ -576,12 +476,6 @@ export const sessionRecordingDataLogic = kea([ ], })), selectors({ - canFallbackToClickHouseForData: [ - (s) => [s.featureFlags], - (featureFlags) => { - return featureFlags[FEATURE_FLAGS.SESSION_RECORDING_ALLOW_V1_SNAPSHOTS] - }, - ], sessionPlayerData: [ (s) => [ s.sessionPlayerMetaData, diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts index fb9494dac817b..02bde3f747399 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts @@ -5,7 +5,7 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' import { useMocks } from '~/mocks/jest' -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { snapshotsAsJSONLines } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import recordingEventsJson from 'scenes/session-recordings/__mocks__/recording_events_query' import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' @@ -22,7 +22,26 @@ describe('sessionRecordingPlayerLogic', () => { beforeEach(() => { useMocks({ get: { - '/api/projects/:team/session_recordings/:id/snapshots': recordingSnapshotsJson, + '/api/projects/:team/session_recordings/:id/snapshots/': (req, res, ctx) => { + // with no sources, returns sources... + if (req.url.searchParams.get('source') === 'blob') { + return res(ctx.text(snapshotsAsJSONLines())) + } + // with no source requested should return sources + return [ + 200, + { + sources: [ + { + source: 'blob', + start_timestamp: '2023-08-11T12:03:36.097000Z', + end_timestamp: '2023-08-11T12:04:52.268000Z', + blob_key: '1691755416097-1691755492268', + }, + ], + }, + ] + }, '/api/projects/:team/session_recordings/:id': recordingMetaJson, }, delete: { @@ -81,6 +100,10 @@ describe('sessionRecordingPlayerLogic', () => { await expectLogic(logic).toDispatchActions([ sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshots, + // once to gather sources + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsV2, + // once to load source from that + sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsV2, sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadRecordingSnapshotsSuccess, ]) diff --git a/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts b/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts index 435a97a699015..49febaac84dbb 100644 --- a/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts +++ b/frontend/src/scenes/session-recordings/player/utils/segmenter.test.ts @@ -1,4 +1,4 @@ -import recordingSnapshotsJson from 'scenes/session-recordings/__mocks__/recording_snapshots.json' +import { sortedRecordingSnapshots } from 'scenes/session-recordings/__mocks__/recording_snapshots' import recordingMetaJson from 'scenes/session-recordings/__mocks__/recording_meta.json' import { createSegments } from './segmenter' import { convertSnapshotsResponse } from '../sessionRecordingDataLogic' @@ -7,7 +7,7 @@ import { RecordingSnapshot } from '~/types' describe('segmenter', () => { it('matches snapshots', async () => { - const snapshots = convertSnapshotsResponse(recordingSnapshotsJson.snapshot_data_by_window_id) + const snapshots = convertSnapshotsResponse(sortedRecordingSnapshots().snapshot_data_by_window_id) const segments = createSegments( snapshots, dayjs(recordingMetaJson.start_time), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1a48f699371b6..fa34b117d07c6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -665,16 +665,8 @@ export interface SessionRecordingSnapshotSource { } export interface SessionRecordingSnapshotResponse { - // Future interface sources?: SessionRecordingSnapshotSource[] snapshots?: EncodedRecordingSnapshot[] - - // legacy interface - next?: string - // When loaded from S3 - blob_keys?: string[] - // When loaded from Clickhouse (legacy) - snapshot_data_by_window_id?: Record } export type RecordingSnapshot = eventWithTime & { @@ -684,7 +676,6 @@ export type RecordingSnapshot = eventWithTime & { export interface SessionPlayerSnapshotData { snapshots?: RecordingSnapshot[] sources?: SessionRecordingSnapshotSource[] - next?: string blob_keys?: string[] } diff --git a/plugin-server/functional_tests/api.ts b/plugin-server/functional_tests/api.ts index cb6ba5e5d5e99..9c23f32ad3fac 100644 --- a/plugin-server/functional_tests/api.ts +++ b/plugin-server/functional_tests/api.ts @@ -5,6 +5,7 @@ import parsePrometheusTextFormat from 'parse-prometheus-text-format' import { PoolClient } from 'pg' import { defaultConfig } from '../src/config/config' +import { KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS } from '../src/config/kafka-topics' import { ActionStep, Hook, @@ -13,8 +14,7 @@ import { PluginLogEntry, RawAction, RawClickHouseEvent, - RawPerformanceEvent, - RawSessionRecordingEvent, + RawSessionReplayEvent, } from '../src/types' import { PostgresRouter, PostgresUse } from '../src/utils/db/postgres' import { parseRawClickHouseEvent } from '../src/utils/event' @@ -62,8 +62,8 @@ export const capture = async ({ now = new Date(), $set = undefined, $set_once = undefined, - topic = ['$performance_event', '$snapshot'].includes(event) - ? 'session_recording_events' + topic = ['$performance_event', '$snapshot_items'].includes(event) + ? KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS : 'events_plugin_ingestion', }: { teamId: number | null @@ -80,7 +80,7 @@ export const capture = async ({ $set_once?: object }) => { // WARNING: this capture method is meant to simulate the ingestion of events - // from the capture endpoint, but there is no guarantee that is is 100% + // from the capture endpoint, but there is no guarantee that it is 100% // accurate. return await produce({ topic, @@ -300,27 +300,19 @@ export const fetchPostgresPersons = async (teamId: number) => { return rows } -export const fetchSessionRecordingsEvents = async (teamId: number, uuid?: string) => { +export const fetchSessionReplayEvents = async (teamId: number, sessionId?: string) => { const queryResult = (await clickHouseClient.querying( - `SELECT * FROM session_recording_events WHERE team_id = ${teamId} ${ - uuid ? ` AND uuid = '${uuid}'` : '' - } ORDER BY timestamp ASC` - )) as unknown as ClickHouse.ObjectQueryResult + `SELECT min(min_first_timestamp) as min_fs_ts, any(team_id), any(distinct_id), session_id FROM session_replay_events WHERE team_id = ${teamId} ${ + sessionId ? ` AND session_id = '${sessionId}'` : '' + } group by session_id ORDER BY min_fs_ts ASC` + )) as unknown as ClickHouse.ObjectQueryResult return queryResult.data.map((event) => { return { ...event, - snapshot_data: event.snapshot_data ? JSON.parse(event.snapshot_data) : null, } }) } -export const fetchPerformanceEvents = async (teamId: number) => { - const queryResult = (await clickHouseClient.querying( - `SELECT * FROM performance_events WHERE team_id = ${teamId} ORDER BY timestamp ASC` - )) as unknown as ClickHouse.ObjectQueryResult - return queryResult.data -} - export const fetchPluginConsoleLogEntries = async (pluginConfigId: number) => { const { data: logEntries } = (await clickHouseClient.querying(` SELECT * FROM plugin_log_entries diff --git a/plugin-server/functional_tests/session-recordings.test.ts b/plugin-server/functional_tests/session-recordings.test.ts index aaf86be6b5392..62075bc6bd10f 100644 --- a/plugin-server/functional_tests/session-recordings.test.ts +++ b/plugin-server/functional_tests/session-recordings.test.ts @@ -1,135 +1,92 @@ -import { Consumer, Kafka, KafkaMessage, logLevel } from 'kafkajs' +import fetch from 'node-fetch' import { v4 as uuidv4 } from 'uuid' -import { defaultConfig } from '../src/config/config' +import { KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS } from '../src/config/kafka-topics' import { UUIDT } from '../src/utils/utils' -import { - capture, - createOrganization, - createTeam, - fetchPerformanceEvents, - fetchSessionRecordingsEvents, - getMetric, -} from './api' +import { capture, createOrganization, createTeam, fetchSessionReplayEvents, getMetric } from './api' import { waitForExpect } from './expectations' import { produce } from './kafka' -let kafka: Kafka let organizationId: string -let dlq: KafkaMessage[] -let dlqConsumer: Consumer - beforeAll(async () => { - kafka = new Kafka({ brokers: [defaultConfig.KAFKA_HOSTS], logLevel: logLevel.NOTHING }) - - // Make sure the dlq topic exists before starting the consumer - const admin = kafka.admin() - await admin.createTopics({ topics: [{ topic: 'session_recording_events_dlq' }] }) - await admin.disconnect() - - dlq = [] - dlqConsumer = kafka.consumer({ groupId: 'session_recording_events_test' }) - await dlqConsumer.subscribe({ topic: 'session_recording_events_dlq', fromBeginning: true }) - await dlqConsumer.run({ - eachMessage: ({ message }) => { - dlq.push(message) - return Promise.resolve() - }, - }) - organizationId = await createOrganization() }) -afterAll(async () => { - await dlqConsumer.disconnect() -}) - -test.concurrent( - `snapshot captured, processed, ingested`, - async () => { - const teamId = await createTeam(organizationId) - const distinctId = new UUIDT().toString() - const uuid = new UUIDT().toString() - const sessionId = new UUIDT().toString() - - await capture({ - teamId, - distinctId, - uuid, - event: '$snapshot', - properties: { - $session_id: sessionId, - $window_id: 'abc1234', - $snapshot_data: 'yes way', - }, - }) +test.skip(`snapshot captured, processed, ingested`, async () => { + const teamId = await createTeam(organizationId) + const distinctId = new UUIDT().toString() + const uuid = new UUIDT().toString() + const sessionId = new UUIDT().toString() - const events = await waitForExpect(async () => { - const events = await fetchSessionRecordingsEvents(teamId) - expect(events.length).toBe(1) - return events - }) + await capture({ + teamId, + distinctId, + uuid, + event: '$snapshot_items', + properties: { + $session_id: sessionId, + $window_id: 'abc1234', + $snapshot_items: ['yes way'], + }, + }) - expect(events[0]).toEqual({ - _offset: expect.any(Number), - _timestamp: expect.any(String), - click_count: 0, - created_at: expect.any(String), - distinct_id: distinctId, - events_summary: [], - first_event_timestamp: null, - has_full_snapshot: 0, - keypress_count: 0, - last_event_timestamp: null, - session_id: sessionId, - snapshot_data: 'yes way', - team_id: teamId, - timestamp: expect.any(String), - timestamps_summary: [], - urls: [], - uuid: uuid, - window_id: 'abc1234', - }) - }, - 20000 -) + const events = await waitForExpect(async () => { + const events = await fetchSessionReplayEvents(teamId, sessionId) + expect(events.length).toBe(1) + return events + }) -test.concurrent( - `snapshot captured, processed, ingested with no team_id set`, - async () => { - const token = uuidv4() - const teamId = await createTeam(organizationId, undefined, token) - const distinctId = new UUIDT().toString() - const uuid = new UUIDT().toString() + expect(events[0]).toEqual({ + _offset: expect.any(Number), + _timestamp: expect.any(String), + click_count: 0, + created_at: expect.any(String), + distinct_id: distinctId, + events_summary: [], + first_event_timestamp: null, + has_full_snapshot: 0, + keypress_count: 0, + last_event_timestamp: null, + session_id: sessionId, + snapshot_data: 'yes way', + team_id: teamId, + timestamp: expect.any(String), + timestamps_summary: [], + urls: [], + uuid: uuid, + window_id: 'abc1234', + }) +}, 20000) - await capture({ - teamId: null, - distinctId, - uuid, - event: '$snapshot', - properties: { - $session_id: '1234abc', - $snapshot_data: 'yes way', - }, - token, - sentAt: new Date(), - eventTime: new Date(), - now: new Date(), - }) +test.skip(`snapshot captured, processed, ingested with no team_id set`, async () => { + const token = uuidv4() + const teamId = await createTeam(organizationId, undefined, token) + const distinctId = new UUIDT().toString() + const uuid = new UUIDT().toString() - await waitForExpect(async () => { - const events = await fetchSessionRecordingsEvents(teamId) - expect(events.length).toBe(1) + await capture({ + teamId: null, + distinctId, + uuid, + event: '$snapshot_items', + properties: { + $session_id: '1234abc', + $snapshot_items: ['yes way'], + }, + token, + sentAt: new Date(), + eventTime: new Date(), + now: new Date(), + }) - // processEvent did not modify - expect(events[0].snapshot_data).toEqual('yes way') - }) - }, - 20000 -) + await waitForExpect(async () => { + const events = await fetchSessionReplayEvents(teamId) + expect(events.length).toBe(1) + }) +}, 20000) -test.concurrent(`recording events not ingested to ClickHouse if team is opted out`, async () => { +test.skip(`recording events not ingested to ClickHouse if team is opted out`, async () => { // NOTE: to have something we can assert on in the positive to ensure that // we had tried to ingest the recording for the team with the opted out // session recording status, we create a team that is opted in and then @@ -145,10 +102,10 @@ test.concurrent(`recording events not ingested to ClickHouse if team is opted ou teamId: null, distinctId: new UUIDT().toString(), uuid: uuidOptedOut, - event: '$snapshot', + event: '$snapshot_items', properties: { $session_id: '1234abc', - $snapshot_data: 'yes way', + $snapshot_items: ['yes way'], }, token: tokenOptedOut, sentAt: new Date(), @@ -164,10 +121,10 @@ test.concurrent(`recording events not ingested to ClickHouse if team is opted ou teamId: null, distinctId: new UUIDT().toString(), uuid: uuidOptedIn, - event: '$snapshot', + event: '$snapshot_items', properties: { $session_id: '1234abc', - $snapshot_data: 'yes way', + $snapshot_items: ['yes way'], }, token: tokenOptedIn, sentAt: new Date(), @@ -176,7 +133,7 @@ test.concurrent(`recording events not ingested to ClickHouse if team is opted ou }) await waitForExpect(async () => { - const events = await fetchSessionRecordingsEvents(teamOptedInId) + const events = await fetchSessionReplayEvents(teamOptedInId) expect(events.length).toBe(1) }) @@ -184,126 +141,10 @@ test.concurrent(`recording events not ingested to ClickHouse if team is opted ou // and that the consumer produceAndFlushs messages in the order they are consumed. // TODO: add some side-effect we can assert on rather than relying on the // partitioning / ordering setup e.g. an ingestion warning. - const events = await fetchSessionRecordingsEvents(teamOptedOutId, uuidOptedOut) + const events = await fetchSessionReplayEvents(teamOptedOutId) expect(events.length).toBe(0) }) -test.concurrent( - `ingests $performance_event`, - async () => { - const teamId = await createTeam(organizationId) - const distinctId = new UUIDT().toString() - const uuid = new UUIDT().toString() - const sessionId = new UUIDT().toString() - const now = new Date() - - const properties = { - // Taken from a real event from the JS - '0': 'resource', - '1': now.getTime(), - '2': 'http://localhost:8000/api/projects/1/session_recordings', - '3': 10737.89999999106, - '4': 0, - '5': 0, - '6': 0, - '7': 10737.89999999106, - '8': 10737.89999999106, - '9': 10737.89999999106, - '10': 10737.89999999106, - '11': 0, - '12': 10737.89999999106, - '13': 10745.09999999404, - '14': 11121.70000000298, - '15': 11122.20000000298, - '16': 73374, - '17': 1767, - '18': 'fetch', - '19': 'http/1.1', - '20': 'non-blocking', - '22': 2067, - '39': 384.30000001192093, - '40': now.getTime() + 1000, - token: 'phc_234', - $session_id: sessionId, - $window_id: '1853a793ad424a5-017f7473b057f1-17525635-384000-1853a793ad524dc', - distinct_id: '5AzhubH8uMghFHxXq0phfs14JOjH6SA2Ftr1dzXj7U4', - $current_url: 'http://localhost:8000/recordings/recent', - } - - await capture({ - teamId, - distinctId, - uuid, - event: '$performance_event', - properties, - token: null, - sentAt: now, - eventTime: now, - now, - }) - - const events = await waitForExpect(async () => { - const events = await fetchPerformanceEvents(teamId) - expect(events.length).toBe(1) - return events - }) - - expect(events[0]).toEqual({ - session_id: sessionId, - _offset: expect.any(Number), - _partition: expect.any(Number), - _timestamp: expect.any(String), - connect_end: 10737.89999999106, - connect_start: 10737.89999999106, - current_url: 'http://localhost:8000/recordings/recent', - decoded_body_size: 73374, - distinct_id: distinctId, - dom_complete: 0, - dom_content_loaded_event: 0, - dom_interactive: 0, - domain_lookup_end: 10737.89999999106, - domain_lookup_start: 10737.89999999106, - duration: 384.30000001192093, - encoded_body_size: 1767, - entry_type: 'resource', - fetch_start: 10737.89999999106, - initiator_type: 'fetch', - largest_contentful_paint_element: '', - largest_contentful_paint_id: '', - largest_contentful_paint_load_time: 0, - largest_contentful_paint_render_time: 0, - largest_contentful_paint_size: 0, - largest_contentful_paint_url: '', - load_event_end: 0, - load_event_start: 0, - name: 'http://localhost:8000/api/projects/1/session_recordings', - navigation_type: '', - next_hop_protocol: 'http/1.1', - pageview_id: '', - redirect_count: 0, - redirect_end: 0, - redirect_start: 0, - render_blocking_status: 'non-blocking', - request_start: 10745.09999999404, - response_end: 11122.20000000298, - response_start: 11121.70000000298, - response_status: 0, - secure_connection_start: 0, - start_time: 10737.89999999106, - team_id: teamId, - time_origin: expect.any(String), - timestamp: expect.any(String), - transfer_size: 2067, - unload_event_end: 0, - unload_event_start: 0, - uuid: uuid, - window_id: '1853a793ad424a5-017f7473b057f1-17525635-384000-1853a793ad524dc', - worker_start: 0, - }) - }, - 20000 -) - test.concurrent(`liveness check endpoint works`, async () => { await waitForExpect(async () => { const response = await fetch('http://localhost:6738/_health') @@ -312,44 +153,37 @@ test.concurrent(`liveness check endpoint works`, async () => { const body = await response.json() expect(body).toEqual( expect.objectContaining({ - checks: expect.objectContaining({ 'session-recordings': 'ok' }), + checks: expect.objectContaining({ 'session-recordings-blob': 'ok' }), }) ) }) }) -test.concurrent( - `consumer handles empty messages`, - async () => { - const key = uuidv4() - - await produce({ topic: 'session_recording_events', message: null, key }) - - await waitForExpect(() => { - const messages = dlq.filter((message) => message.key?.toString() === key) - expect(messages.length).toBe(1) - }) - }, - 20000 -) - -test.concurrent('consumer updates timestamp exported to prometheus', async () => { +test.skip('consumer updates timestamp exported to prometheus', async () => { // NOTE: it may be another event other than the one we emit here that causes // the gauge to increase, but pushing this event through should at least // ensure that the gauge is updated. const metricBefore = await getMetric({ name: 'latest_processed_timestamp_ms', type: 'GAUGE', - labels: { topic: 'session_recording_events', partition: '0', groupId: 'session-recordings' }, + labels: { + topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, + partition: '0', + groupId: 'session-recordings-blob', + }, }) - await produce({ topic: 'session_recording_events', message: Buffer.from(''), key: '' }) + await produce({ topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, message: Buffer.from(''), key: '' }) await waitForExpect(async () => { const metricAfter = await getMetric({ name: 'latest_processed_timestamp_ms', type: 'GAUGE', - labels: { topic: 'session_recording_events', partition: '0', groupId: 'session-recordings' }, + labels: { + topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, + partition: '0', + groupId: 'session-recordings-blob', + }, }) expect(metricAfter).toBeGreaterThan(metricBefore) expect(metricAfter).toBeLessThan(Date.now()) // Make sure, e.g. we're not setting micro seconds @@ -357,18 +191,42 @@ test.concurrent('consumer updates timestamp exported to prometheus', async () => }, 10_000) }) -test.concurrent(`handles invalid JSON`, async () => { - const key = uuidv4() - - await produce({ topic: 'session_recording_events', message: Buffer.from('invalid json'), key }) - - await waitForExpect(() => { - const messages = dlq.filter((message) => message.key?.toString() === key) - expect(messages.length).toBe(1) - }) -}) +function makeSessionMessage( + teamId: number, + sessionId: string, + uuid?: string +): { + teamId: number | null + distinctId: string + uuid: string + event: string + properties?: object | undefined + token?: string | null | undefined + sentAt?: Date | undefined + eventTime?: Date | undefined + now?: Date | undefined + topic?: string | undefined + $set?: object | undefined + $set_once?: object | undefined +} { + return { + teamId: teamId, + distinctId: new UUIDT().toString(), + uuid: uuid || new UUIDT().toString(), + event: '$snapshot_items', + properties: { + $session_id: sessionId, + $snapshot_items: ['yes way'], + }, + sentAt: new Date(), + eventTime: new Date(), + now: new Date(), + topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, + } +} -test.concurrent(`handles message with no token or with token and no associated team_id`, async () => { +// TODO we can't query for replay events by UUID +test.skip(`handles message with no token or with token and no associated team_id`, async () => { // NOTE: Here we are relying on the topic only having a single partition, // which ensures that if the last message we send is in ClickHouse, then // that should mean that the previous messages have already been processed. @@ -382,48 +240,30 @@ test.concurrent(`handles message with no token or with token and no associated t const noAssociatedTeamKey = uuidv4() const noTokenUuid = uuidv4() const noAssociatedTeamUuid = uuidv4() - const uuid = uuidv4() await produce({ - topic: 'session_recording_events', + topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, message: Buffer.from(JSON.stringify({ uuid: noTokenUuid, data: JSON.stringify({}) })), key: noTokenKey, }) await produce({ - topic: 'session_recording_events', + topic: KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, message: Buffer.from( JSON.stringify({ uuid: noAssociatedTeamUuid, token: 'no associated team', data: JSON.stringify({}) }) ), key: noAssociatedTeamKey, }) - await capture({ - teamId: teamId, - distinctId: new UUIDT().toString(), - uuid: uuid, - event: '$snapshot', - properties: { - $session_id: '1234abc', - $snapshot_data: 'yes way', - }, - sentAt: new Date(), - eventTime: new Date(), - now: new Date(), - topic: 'session_recording_events', - }) + await capture(makeSessionMessage(teamId, 'should be ingested')) await waitForExpect(async () => { - const events = await fetchSessionRecordingsEvents(teamId, uuid) + const events = await fetchSessionReplayEvents(teamId, 'should be ingested') expect(events.length).toBe(1) }) - // These shouldn't have been DLQ'd - expect(dlq.filter((message) => message.key?.toString() === noTokenKey).length).toBe(0) - expect(dlq.filter((message) => message.key?.toString() === noAssociatedTeamKey).length).toBe(0) - // And they shouldn't have been ingested into ClickHouse - expect((await fetchSessionRecordingsEvents(teamId, noTokenUuid)).length).toBe(0) - expect((await fetchSessionRecordingsEvents(teamId, noAssociatedTeamUuid)).length).toBe(0) + expect((await fetchSessionReplayEvents(teamId, noTokenUuid)).length).toBe(0) + expect((await fetchSessionReplayEvents(teamId, noAssociatedTeamUuid)).length).toBe(0) }) // TODO: implement schema validation and add a test. diff --git a/plugin-server/functional_tests/web-performance-events.test.ts b/plugin-server/functional_tests/web-performance-events.test.ts deleted file mode 100644 index e05dad6bac9f4..0000000000000 --- a/plugin-server/functional_tests/web-performance-events.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { UUIDT } from '../src/utils/utils' -import { capture, createOrganization, createTeam, fetchEvents, fetchPerformanceEvents } from './api' -import { waitForExpect } from './expectations' - -let organizationId: string - -beforeAll(async () => { - organizationId = await createOrganization() -}) - -test.concurrent( - `peformance event ingestion: captured, processed, ingested`, - async () => { - const teamId = await createTeam(organizationId) - const distinctId = new UUIDT().toString() - const uuid = new UUIDT().toString() - - await capture({ - teamId, - distinctId, - uuid, - event: '$performance_event', - properties: { - '0': 'resource', - $session_id: '$session_id_1', - $window_id: '$window_id_1', - $pageview_id: '$pageview_id_1', - $current_url: '$current_url_1', - }, - }) - - const perfEvents = await waitForExpect(async () => { - const perfEvents = await fetchPerformanceEvents(teamId) - expect(perfEvents.length).toBe(1) - return perfEvents - }) - const events = await fetchEvents(teamId) - expect(events.length).toBe(0) - - expect(perfEvents.length).toBe(1) - - // processEvent did not modify - expect(perfEvents[0]).toMatchObject({ - entry_type: 'resource', - session_id: '$session_id_1', - window_id: '$window_id_1', - pageview_id: '$pageview_id_1', - current_url: '$current_url_1', - }) - }, - 20000 -) diff --git a/plugin-server/src/capabilities.ts b/plugin-server/src/capabilities.ts index 1468412020651..bbb8a49823ed7 100644 --- a/plugin-server/src/capabilities.ts +++ b/plugin-server/src/capabilities.ts @@ -18,7 +18,6 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin processPluginJobs: true, processAsyncOnEventHandlers: true, processAsyncWebhooksHandlers: true, - sessionRecordingIngestion: true, sessionRecordingBlobIngestion: true, transpileFrontendApps: true, preflightSchedules: true, @@ -30,7 +29,6 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin return { mmdb: true, ingestion: true, - sessionRecordingIngestion: true, ...sharedCapabilities, } case PluginServerMode.ingestion_overflow: @@ -51,11 +49,6 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin ingestion: true, ...sharedCapabilities, } - case PluginServerMode.recordings_ingestion: - return { - sessionRecordingIngestion: true, - ...sharedCapabilities, - } case PluginServerMode.recordings_blob_ingestion: return { sessionRecordingBlobIngestion: true, diff --git a/plugin-server/src/config/kafka-topics.ts b/plugin-server/src/config/kafka-topics.ts index 0926d239447c7..4fd3e54b043b5 100644 --- a/plugin-server/src/config/kafka-topics.ts +++ b/plugin-server/src/config/kafka-topics.ts @@ -27,9 +27,6 @@ export const KAFKA_SCHEDULED_TASKS_DLQ = `${prefix}scheduled_tasks_dlq${suffix}` export const KAFKA_METRICS_TIME_TO_SEE_DATA = `${prefix}clickhouse_metrics_time_to_see_data${suffix}` export const KAFKA_PERSON_OVERRIDE = `${prefix}clickhouse_person_override${suffix}` -// read session recording events from Kafka -export const KAFKA_SESSION_RECORDING_EVENTS = `${prefix}session_recording_events${suffix}` -export const KAFKA_SESSION_RECORDING_EVENTS_DLQ = `${prefix}session_recording_events_dlq${suffix}` // read session recording snapshot items export const KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS = `${prefix}session_recording_snapshot_item_events${suffix}` // write session recording and replay events to ClickHouse diff --git a/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v1.ts b/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v1.ts deleted file mode 100644 index bfb6aaefef061..0000000000000 --- a/plugin-server/src/main/ingestion-queues/session-recording/session-recordings-consumer-v1.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { PluginEvent } from '@posthog/plugin-scaffold' -import { captureException, captureMessage } from '@sentry/node' -import { DateTime } from 'luxon' -import { HighLevelProducer as RdKafkaProducer, Message, NumberNullUndefined } from 'node-rdkafka' - -import { - KAFKA_CLICKHOUSE_SESSION_RECORDING_EVENTS, - KAFKA_CLICKHOUSE_SESSION_REPLAY_EVENTS, - KAFKA_PERFORMANCE_EVENTS, - KAFKA_SESSION_RECORDING_EVENTS, - KAFKA_SESSION_RECORDING_EVENTS_DLQ, -} from '../../../config/kafka-topics' -import { startBatchConsumer } from '../../../kafka/batch-consumer' -import { createRdConnectionConfigFromEnvVars, createRdProducerConfigFromEnvVars } from '../../../kafka/config' -import { retryOnDependencyUnavailableError } from '../../../kafka/error-handling' -import { - createKafkaProducer, - disconnectProducer, - flushProducer, - KafkaProducerConfig, - produce, -} from '../../../kafka/producer' -import { PipelineEvent, RawEventMessage, Team } from '../../../types' -import { KafkaConfig } from '../../../utils/db/hub' -import { status } from '../../../utils/status' -import { - createPerformanceEvent, - createSessionRecordingEvent, - createSessionReplayEvent, - SummarizedSessionRecordingEvent, -} from '../../../worker/ingestion/process-event' -import { TeamManager } from '../../../worker/ingestion/team-manager' -import { parseEventTimestamp } from '../../../worker/ingestion/timestamps' -import { eventDroppedCounter } from '../metrics' - -export const startSessionRecordingEventsConsumerV1 = async ({ - teamManager, - kafkaConfig, - kafkaProducerConfig, - consumerMaxBytes, - consumerMaxBytesPerPartition, - consumerMaxWaitMs, - consumerErrorBackoffMs, - batchingTimeoutMs, - topicCreationTimeoutMs, -}: { - teamManager: TeamManager - kafkaConfig: KafkaConfig - kafkaProducerConfig: KafkaProducerConfig - consumerMaxBytes: number - consumerMaxBytesPerPartition: number - consumerMaxWaitMs: number - consumerErrorBackoffMs: number - batchingTimeoutMs: number - topicCreationTimeoutMs: number -}) => { - /* - For Session Recordings we need to prepare the data for ClickHouse. - Additionally, we process `$performance_event` events which are closely - tied to session recording events. - - We use the node-rdkafka library for handling consumption and production - from Kafka. Note that this is different from the other consumers as this - is a test bed for consumer improvements, which should be ported to the - other consumers. - - We consume batches of messages, process these to completion, including - getting acknowledgements that the messages have been pushed to Kafka, - then commit the offsets of the messages we have processed. We do this - instead of going completely stream happy just to keep the complexity - low. We may well move this ingester to a different framework - specifically for stream processing so no need to put too much work into - this. - */ - - const groupId = 'session-recordings' - const sessionTimeout = 30000 - const fetchBatchSize = 500 - - status.info('🔁', 'Starting session recordings consumer') - - const connectionConfig = createRdConnectionConfigFromEnvVars(kafkaConfig) - const producerConfig = createRdProducerConfigFromEnvVars(kafkaProducerConfig) - const producer = await createKafkaProducer(connectionConfig, producerConfig) - - const eachBatchWithContext = eachBatch({ - teamManager, - producer, - }) - - // Create a node-rdkafka consumer that fetches batches of messages, runs - // eachBatchWithContext, then commits offsets for the batch. - const consumer = await startBatchConsumer({ - connectionConfig, - groupId, - topic: KAFKA_SESSION_RECORDING_EVENTS, - autoCommit: true, - sessionTimeout, - consumerMaxBytesPerPartition, - consumerMaxBytes, - consumerMaxWaitMs, - consumerErrorBackoffMs, - fetchBatchSize, - batchingTimeoutMs, - topicCreationTimeoutMs, - eachBatch: eachBatchWithContext, - }) - - // Make sure to disconnect the producer after we've finished consuming. - consumer.join().finally(async () => { - await disconnectProducer(producer) - }) - - return consumer -} - -export const eachBatch = - ({ teamManager, producer }: { teamManager: TeamManager; producer: RdKafkaProducer }) => - async (messages: Message[]) => { - // To start with, we simply process each message in turn, - // without attempting to perform any concurrency. There is a lot - // of caching e.g. for team lookups so not so much IO going on - // anyway. - // - // Where we do allow some parallelism is in the producing to - // Kafka. The eachMessage function will return a Promise for any - // produce requests, rather than blocking on them. This way we - // can handle errors for the main processing, and the production - // errors separately. - // - // For the main processing errors we will check to see if they - // are intermittent errors, and if so, we will retry the - // processing of the message. If the error is not intermittent, - // we will simply stop processing as we assume this is a code - // issue that will need to be resolved. We use - // DependencyUnavailableError error to distinguish between - // intermittent and permanent errors. - const pendingProduceRequests: Promise[] = [] - const eachMessageWithContext = eachMessage({ teamManager, producer }) - - for (const message of messages) { - const results = await retryOnDependencyUnavailableError(() => eachMessageWithContext(message)) - if (results) { - pendingProduceRequests.push(...results) - } - } - - // On each loop, we flush the producer to ensure that all messages - // are sent to Kafka. - try { - await flushProducer(producer) - } catch (error) { - // Rather than handling errors from flush, we instead handle - // errors per produce request, which gives us a little more - // flexibility in terms of deciding if it is a terminal - // error or not. - } - - // We wait on all the produce requests to complete. After the - // flush they should all have been resolved/rejected already. If - // we get an intermittent error, such as a Kafka broker being - // unavailable, we will throw. We are relying on the Producer - // already having handled retries internally. - for (const produceRequest of pendingProduceRequests) { - try { - await produceRequest - } catch (error) { - status.error('🔁', 'main_loop_error', { error }) - - if (error?.isRetriable) { - // We assume the if the error is retriable, then we - // are probably in a state where e.g. Kafka is down - // temporarily and we would rather simply throw and - // have the process restarted. - throw error - } - } - } - } - -const eachMessage = - ({ teamManager, producer }: { teamManager: TeamManager; producer: RdKafkaProducer }) => - async (message: Message) => { - // For each message, we: - // - // 1. Check that the message is valid. If not, send it to the DLQ. - // 2. Parse the message and extract the event. - // 3. Get the associated team for the event. - // 4. Convert the event to something we can insert into ClickHouse. - - if (!message.value || !message.timestamp) { - status.warn('⚠️', 'invalid_message', { - reason: 'empty', - offset: message.offset, - partition: message.partition, - }) - return [ - produce({ - producer, - topic: KAFKA_SESSION_RECORDING_EVENTS_DLQ, - value: message.value, - key: message.key ? Buffer.from(message.key) : null, - }), - ] - } - - let messagePayload: RawEventMessage - let event: PipelineEvent - - try { - // NOTE: we need to parse the JSON for these events because we - // need to add in the team_id to events, as it is possible due - // to a drive to remove postgres dependency on the the capture - // endpoint we may only have `token`. - messagePayload = JSON.parse(message.value.toString()) - event = JSON.parse(messagePayload.data) - } catch (error) { - status.warn('⚠️', 'invalid_message', { - reason: 'invalid_json', - error: error, - offset: message.offset, - partition: message.partition, - }) - return [ - produce({ - producer, - topic: KAFKA_SESSION_RECORDING_EVENTS_DLQ, - value: message.value, - key: message.key ? Buffer.from(message.key) : null, - }), - ] - } - - status.debug('⬆️', 'processing_session_recording', { uuid: messagePayload.uuid }) - - if (messagePayload.team_id == null && !messagePayload.token) { - eventDroppedCounter - .labels({ - event_type: 'session_recordings', - drop_cause: 'no_token', - }) - .inc() - status.warn('⚠️', 'invalid_message', { - reason: 'no_token', - offset: message.offset, - partition: message.partition, - }) - return - } - - let team: Team | null = null - - if (messagePayload.team_id != null) { - team = await teamManager.fetchTeam(messagePayload.team_id) - } else if (messagePayload.token) { - team = await teamManager.getTeamByToken(messagePayload.token) - } - - if (team == null) { - eventDroppedCounter - .labels({ - event_type: 'session_recordings', - drop_cause: 'invalid_token', - }) - .inc() - status.warn('⚠️', 'invalid_message', { - reason: 'team_not_found', - offset: message.offset, - partition: message.partition, - }) - return - } - - if (team.session_recording_opt_in) { - try { - if (event.event === '$snapshot_items') { - eventDroppedCounter - .labels({ - event_type: 'session_recordings', - drop_cause: 'recordings-consumer-does-not-handle-snapshot-items', - }) - .inc() - } else if (event.event === '$snapshot') { - const clickHouseRecord = createSessionRecordingEvent( - messagePayload.uuid, - team.id, - messagePayload.distinct_id, - parseEventTimestamp(event as PluginEvent), - event.properties || {} - ) - - let replayRecord: null | SummarizedSessionRecordingEvent = null - try { - const properties = event.properties || {} - const shouldCreateReplayEvents = (properties['$snapshot_consumer'] ?? 'v1') === 'v1' - const eventsSummary: any[] = properties.$snapshot_data?.events_summary || [] - - if (shouldCreateReplayEvents && eventsSummary.length) { - replayRecord = createSessionReplayEvent( - messagePayload.uuid, - team.id, - messagePayload.distinct_id, - properties['$session_id'], - eventsSummary - ) - } - // the replay record timestamp has to be valid and be within a reasonable diff from now - if (replayRecord !== null) { - const asDate = DateTime.fromSQL(replayRecord.first_timestamp) - if (!asDate.isValid || Math.abs(asDate.diffNow('months').months) >= 0.99) { - captureMessage( - `Invalid replay record timestamp: ${replayRecord.first_timestamp} for event ${messagePayload.uuid}`, - { - extra: { - replayRecord, - uuid: clickHouseRecord.uuid, - timestamp: clickHouseRecord.timestamp, - }, - tags: { - team: team.id, - session_id: clickHouseRecord.session_id, - }, - } - ) - replayRecord = null - } - } - } catch (e) { - status.warn('??', 'session_replay_summarizer_error', { error: e }) - captureException(e, { - extra: { - clickHouseRecord: { - uuid: clickHouseRecord.uuid, - timestamp: clickHouseRecord.timestamp, - snapshot_data: clickHouseRecord.snapshot_data, - }, - replayRecord, - }, - tags: { - team: team.id, - session_id: clickHouseRecord.session_id, - chunk_index: event.properties?.['$snapshot_data']?.chunk_index || 'unknown', - chunk_count: event.properties?.['$snapshot_data']?.chunk_count || 'unknown', - }, - }) - } - - const producePromises = [ - produce({ - producer, - topic: KAFKA_CLICKHOUSE_SESSION_RECORDING_EVENTS, - value: Buffer.from(JSON.stringify(clickHouseRecord)), - key: message.key ? Buffer.from(message.key) : null, - }), - ] - - if (replayRecord) { - producePromises.push( - produce({ - producer, - topic: KAFKA_CLICKHOUSE_SESSION_REPLAY_EVENTS, - value: Buffer.from(JSON.stringify(replayRecord)), - key: message.key ? Buffer.from(message.key) : null, - }) - ) - } - return producePromises - } else if (event.event === '$performance_event') { - const clickHouseRecord = createPerformanceEvent( - messagePayload.uuid, - team.id, - messagePayload.distinct_id, - event.properties || {} - ) - - return [ - produce({ - producer, - topic: KAFKA_PERFORMANCE_EVENTS, - value: Buffer.from(JSON.stringify(clickHouseRecord)), - key: message.key ? Buffer.from(message.key) : null, - }), - ] - } else { - status.warn('⚠️', 'invalid_message', { - reason: 'invalid_event_type', - type: event.event, - offset: message.offset, - partition: message.partition, - }) - eventDroppedCounter - .labels({ - event_type: 'session_recordings', - drop_cause: 'invalid_event_type', - }) - .inc() - } - } catch (error) { - status.error('⚠️', 'processing_error', { - eventId: event.uuid, - error: error, - }) - } - } else { - eventDroppedCounter - .labels({ - event_type: 'session_recordings', - drop_cause: 'disabled', - }) - .inc() - } - } diff --git a/plugin-server/src/main/pluginsServer.ts b/plugin-server/src/main/pluginsServer.ts index 20119c7f08542..01e4369477006 100644 --- a/plugin-server/src/main/pluginsServer.ts +++ b/plugin-server/src/main/pluginsServer.ts @@ -35,7 +35,6 @@ import { startAsyncWebhooksHandlerConsumer, } from './ingestion-queues/on-event-handler-consumer' import { startScheduledTasksConsumer } from './ingestion-queues/scheduled-tasks-consumer' -import { startSessionRecordingEventsConsumerV1 } from './ingestion-queues/session-recording/session-recordings-consumer-v1' import { SessionRecordingIngesterV2 } from './ingestion-queues/session-recording/session-recordings-consumer-v2' import { createHttpServer } from './services/http-server' import { getObjectStorage } from './services/object_storage' @@ -402,30 +401,6 @@ export async function startPluginsServer( hub.lastActivityType = 'serverStart' } - if (capabilities.sessionRecordingIngestion) { - const statsd = hub?.statsd ?? createStatsdClient(serverConfig, null) - const postgres = hub?.postgres ?? new PostgresRouter(serverConfig, statsd) - const teamManager = hub?.teamManager ?? new TeamManager(postgres, serverConfig) - const { - stop, - isHealthy: isSessionRecordingsHealthy, - join, - } = await startSessionRecordingEventsConsumerV1({ - teamManager: teamManager, - kafkaConfig: serverConfig, - kafkaProducerConfig: serverConfig, - consumerMaxBytes: serverConfig.KAFKA_CONSUMPTION_MAX_BYTES, - consumerMaxBytesPerPartition: serverConfig.KAFKA_CONSUMPTION_MAX_BYTES_PER_PARTITION, - consumerMaxWaitMs: serverConfig.KAFKA_CONSUMPTION_MAX_WAIT_MS, - consumerErrorBackoffMs: serverConfig.KAFKA_CONSUMPTION_ERROR_BACKOFF_MS, - batchingTimeoutMs: serverConfig.KAFKA_CONSUMPTION_BATCHING_TIMEOUT_MS, - topicCreationTimeoutMs: serverConfig.KAFKA_TOPIC_CREATION_TIMEOUT_MS, - }) - stopSessionRecordingEventsConsumer = stop - joinSessionRecordingEventsConsumer = join - healthChecks['session-recordings'] = isSessionRecordingsHealthy - } - if (capabilities.sessionRecordingBlobIngestion) { const recordingConsumerConfig = sessionRecordingConsumerConfig(serverConfig) const statsd = hub?.statsd ?? createStatsdClient(serverConfig, null) diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts index ace2221ca65f5..e5ac78a51b09f 100644 --- a/plugin-server/src/types.ts +++ b/plugin-server/src/types.ts @@ -77,7 +77,6 @@ export enum PluginServerMode { jobs = 'jobs', scheduler = 'scheduler', analytics_ingestion = 'analytics-ingestion', - recordings_ingestion = 'recordings-ingestion', recordings_blob_ingestion = 'recordings-blob-ingestion', } @@ -289,7 +288,6 @@ export interface PluginServerCapabilities { processPluginJobs?: boolean processAsyncOnEventHandlers?: boolean processAsyncWebhooksHandlers?: boolean - sessionRecordingIngestion?: boolean sessionRecordingBlobIngestion?: boolean transpileFrontendApps?: boolean // TODO: move this away from pod startup, into a graphile job preflightSchedules?: boolean // Used for instance health checks on hobby deploy, not useful on cloud @@ -939,6 +937,15 @@ export interface RawSessionRecordingEvent { created_at: string } +/** Raw session replay event row from ClickHouse. */ +export interface RawSessionReplayEvent { + min_first_timestamp: string + team_id: number + distinct_id: string + session_id: string + /* TODO what columns do we need */ +} + export interface RawPerformanceEvent { uuid: string team_id: number diff --git a/plugin-server/tests/helpers/kafka.ts b/plugin-server/tests/helpers/kafka.ts index d877805e293b5..775ae674ce86b 100644 --- a/plugin-server/tests/helpers/kafka.ts +++ b/plugin-server/tests/helpers/kafka.ts @@ -11,7 +11,7 @@ import { KAFKA_PERSON_DISTINCT_ID, KAFKA_PERSON_UNIQUE_ID, KAFKA_PLUGIN_LOG_ENTRIES, - KAFKA_SESSION_RECORDING_EVENTS, + KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, } from '../../src/config/kafka-topics' import { PluginsServerConfig } from '../../src/types' import { KAFKA_EVENTS_DEAD_LETTER_QUEUE } from './../../src/config/kafka-topics' @@ -30,7 +30,7 @@ export async function resetKafka(extraServerConfig?: Partial { - const producer = { - produce: jest.fn(), - flush: jest.fn(), - } as any - let postgres: PostgresRouter - let teamManager: TeamManager - let eachBachWithDependencies: any - - beforeEach(() => { - postgres = new PostgresRouter(defaultConfig, undefined) - teamManager = new TeamManager(postgres, {} as any) - eachBachWithDependencies = eachBatch({ producer, teamManager }) - }) - - afterEach(() => { - jest.clearAllMocks() - }) - - test('eachBatch throws on recoverable Kafka errors', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - const error = new LibrdKafkaError({ message: 'test', code: 1, errno: 1, origin: 'test', isRetriable: true }) - producer.produce.mockImplementation( - (_topic: any, _partition: any, _message: any, _key: any, _timestamp: any, _headers: any, cb: any) => - cb(error) - ) - producer.flush.mockImplementation((_timeout: any, cb: any) => cb(null)) - await expect( - eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ team_id: teamId, data: JSON.stringify({ event: '$snapshot' }) }), - }, - ]) - ).rejects.toEqual(error) - }) - - test('eachBatch emits to DLQ and returns on unrecoverable KafkaJS errors', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - const error = new LibrdKafkaError({ message: 'test', code: 1, errno: 1, origin: 'test', isRetriable: false }) - producer.produce.mockImplementation( - (_topic: any, _partition: any, _message: any, _key: any, _timestamp: any, _headers: any, cb: any) => - cb(error) - ) - producer.flush.mockImplementation((_timeout: any, cb: any) => cb(null)) - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ team_id: teamId, data: JSON.stringify({ event: '$snapshot' }) }), - }, - ]) - - // Should have sent to the DLQ. - expect(producer.produce).toHaveBeenCalledTimes(1) - }) - - test('eachBatch emits to only one topic', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ team_id: teamId, data: JSON.stringify({ event: '$snapshot' }) }), - timestamp: 123, - }, - ]) - - expect(producer.produce).toHaveBeenCalledTimes(1) - }) - - test('eachBatch can emit to two topics', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - - const eachBachWithDependencies: any = eachBatch({ producer, teamManager }) - - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ - team_id: teamId, - data: JSON.stringify({ - event: '$snapshot', - properties: { $snapshot_data: { events_summary: [{ timestamp: now() }] } }, - }), - }), - timestamp: 123, - }, - ]) - - expect(producer.produce).toHaveBeenCalledTimes(2) - }) - - test('eachBatch does not emit replay event if set to other consumer', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - - const eachBachWithDependencies: any = eachBatch({ producer, teamManager }) - - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ - team_id: teamId, - data: JSON.stringify({ - event: '$snapshot', - properties: { - $snapshot_data: { events_summary: [{ timestamp: now() }] }, - $snapshot_consumer: 'v2', - }, - }), - }), - timestamp: 123, - }, - ]) - - expect(producer.produce).toHaveBeenCalledTimes(1) - }) - - test('eachBatch does not emit a replay record that is more than a month in the future', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - - const eachBachWithDependencies: any = eachBatch({ producer, teamManager }) - - const aMonthInFuture = DateTime.now().plus({ months: 1 }).toMillis() - - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ - team_id: teamId, - data: JSON.stringify({ - event: '$snapshot', - properties: { $snapshot_data: { events_summary: [{ timestamp: aMonthInFuture }] } }, - }), - }), - timestamp: 123, - }, - ]) - - expect(producer.produce).toHaveBeenCalledTimes(1) - }) - - test('eachBatch does not emit a replay record that is more than a month in the past', async () => { - const organizationId = await createOrganization(postgres) - const teamId = await createTeam(postgres, organizationId) - - const eachBachWithDependencies: any = eachBatch({ producer, teamManager }) - - const aMonthInFuture = DateTime.now().minus({ months: 1 }).toMillis() - - await eachBachWithDependencies([ - { - key: 'test', - value: JSON.stringify({ - team_id: teamId, - data: JSON.stringify({ - event: '$snapshot', - properties: { $snapshot_data: { events_summary: [{ timestamp: aMonthInFuture }] } }, - }), - }), - timestamp: 123, - }, - ]) - - expect(producer.produce).toHaveBeenCalledTimes(1) - }) -}) diff --git a/posthog/api/capture.py b/posthog/api/capture.py index c3b45cafef816..ba07e55f34900 100644 --- a/posthog/api/capture.py +++ b/posthog/api/capture.py @@ -3,7 +3,6 @@ import re import time from datetime import datetime -from random import random from typing import Any, Dict, Iterator, List, Optional, Tuple import structlog @@ -36,7 +35,6 @@ from posthog.metrics import LABEL_RESOURCE_TYPE from posthog.models.utils import UUIDT from posthog.session_recordings.session_recording_helpers import ( - legacy_preprocess_session_recording_events_for_clickhouse, preprocess_replay_events_for_blob_ingestion, split_replay_events, ) @@ -367,21 +365,10 @@ def get_event(request): # NOTE: Whilst we are testing this code we want to track exceptions but allow the events through if anything goes wrong capture_exception(e) - consumer_destination = "v2" if random() <= settings.REPLAY_EVENTS_NEW_CONSUMER_RATIO else "v1" - try: + # split the replay events off as they are passed to kafka separately replay_events, other_events = split_replay_events(events) - processed_replay_events = replay_events - - if len(replay_events) > 0: - # Legacy solution stays in place - processed_replay_events = legacy_preprocess_session_recording_events_for_clickhouse(replay_events) - - # Mark all events so that they are only consumed by one consumer - for event in processed_replay_events: - event["properties"]["$snapshot_consumer"] = consumer_destination - - events = processed_replay_events + other_events + events = other_events except ValueError as e: return cors_response( @@ -459,10 +446,6 @@ def get_event(request): replay_events, settings.SESSION_RECORDING_KAFKA_MAX_REQUEST_SIZE_BYTES ) - # Mark all events so that they are only consumed by one consumer - for event in alternative_replay_events: - event["properties"]["$snapshot_consumer"] = consumer_destination - futures = [] # We want to be super careful with our new ingestion flow for now so the whole thing is separated diff --git a/posthog/api/test/__snapshots__/test_cohort.ambr b/posthog/api/test/__snapshots__/test_cohort.ambr index f1aa91ae794b9..0c8c87f370573 100644 --- a/posthog/api/test/__snapshots__/test_cohort.ambr +++ b/posthog/api/test/__snapshots__/test_cohort.ambr @@ -1,6 +1,6 @@ # name: TestCohort.test_async_deletion_of_cohort ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ SELECT count(DISTINCT person_id) FROM cohortpeople WHERE team_id = 2 @@ -10,7 +10,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.1 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ INSERT INTO cohortpeople SELECT id, 2 as cohort_id, @@ -83,7 +83,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.2 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ SELECT count(DISTINCT person_id) FROM cohortpeople WHERE team_id = 2 @@ -93,7 +93,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.3 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.clear_stale_cohort */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.clear_stale_cohort */ SELECT count() FROM cohortpeople WHERE team_id = 2 @@ -103,7 +103,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.4 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ SELECT count(DISTINCT person_id) FROM cohortpeople WHERE team_id = 2 @@ -113,7 +113,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.5 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ INSERT INTO cohortpeople SELECT id, 2 as cohort_id, @@ -147,7 +147,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.6 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.calculate_cohort_ch */ SELECT count(DISTINCT person_id) FROM cohortpeople WHERE team_id = 2 @@ -157,7 +157,7 @@ --- # name: TestCohort.test_async_deletion_of_cohort.7 ' - /* user_id:116 celery:posthog.tasks.calculate_cohort.clear_stale_cohort */ + /* user_id:115 celery:posthog.tasks.calculate_cohort.clear_stale_cohort */ SELECT count() FROM cohortpeople WHERE team_id = 2 diff --git a/posthog/api/test/test_capture.py b/posthog/api/test/test_capture.py index 8fff90642f112..fae47f35cbd8b 100644 --- a/posthog/api/test/test_capture.py +++ b/posthog/api/test/test_capture.py @@ -37,7 +37,6 @@ from posthog.kafka_client.client import KafkaProducer, sessionRecordingKafkaProducer from posthog.kafka_client.topics import ( KAFKA_EVENTS_PLUGIN_INGESTION_HISTORICAL, - KAFKA_SESSION_RECORDING_EVENTS, KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS, ) from posthog.settings import ( @@ -1175,9 +1174,9 @@ def test_cors_allows_tracing_headers(self, _: str, path: str, headers: List[str] def test_legacy_recording_ingestion_data_sent_to_kafka(self, kafka_produce) -> None: session_id = "some_session_id" self._send_session_recording_event(session_id=session_id) - self.assertEqual(kafka_produce.call_count, 2) + self.assertEqual(kafka_produce.call_count, 1) kafka_topic_used = kafka_produce.call_args_list[0][1]["topic"] - self.assertEqual(kafka_topic_used, KAFKA_SESSION_RECORDING_EVENTS) + self.assertEqual(kafka_topic_used, KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS) key = kafka_produce.call_args_list[0][1]["key"] self.assertEqual(key, session_id) @@ -1202,49 +1201,28 @@ def test_legacy_recording_ingestion_compression_and_transformation(self, kafka_p window_id=window_id, event_data=event_data, ) - self.assertEqual(kafka_produce.call_count, 2) - self.assertEqual(kafka_produce.call_args_list[0][1]["topic"], KAFKA_SESSION_RECORDING_EVENTS) + self.assertEqual(kafka_produce.call_count, 1) + self.assertEqual(kafka_produce.call_args_list[0][1]["topic"], KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS) key = kafka_produce.call_args_list[0][1]["key"] self.assertEqual(key, session_id) data_sent_to_kafka = json.loads(kafka_produce.call_args_list[0][1]["data"]["data"]) - self.assertEqual( - data_sent_to_kafka, - { - "event": "$snapshot", - "properties": { - "$snapshot_consumer": "v1", - "$snapshot_data": { - "chunk_count": 1, - "chunk_id": "fake-uuid", - "chunk_index": 0, - "data": "H4sIAIB3mGAC/42NSwqAQAxD31Fk1m4GUUavIi78ggtR/CxEvLoaPweQQJu2SXoeKRuGmZWBWizBw+GrGipyXfJve+smehZGyh/aRtr+mw2FbqP6LryOmZZOOdPj6/T/1VoiQuWGD4sFq8kRyJlxAaIGxIyyAAAA", - "compression": "gzip-base64", - "has_full_snapshot": False, - "events_summary": [ - { - "type": snapshot_type, - "data": {"source": snapshot_source}, - "timestamp": timestamp, - } - ], - }, - "$session_id": session_id, - "$window_id": window_id, - "distinct_id": distinct_id, - }, - "offset": 1993, + assert data_sent_to_kafka == { + "event": "$snapshot_items", + "properties": { + "$snapshot_items": [ + { + "type": snapshot_type, + "timestamp": timestamp, + "data": {"data": event_data, "source": snapshot_source}, + } + ], + "$session_id": session_id, + "$window_id": window_id, + "distinct_id": distinct_id, }, - ) - - @patch("posthog.kafka_client.client._KafkaProducer.produce") - def test_legacy_recording_ingestion_large_is_split_into_multiple_messages(self, kafka_produce) -> None: - self._send_session_recording_event(event_data=large_data_array) - topic_counter = Counter([call[1]["topic"] for call in kafka_produce.call_args_list]) - - assert topic_counter == Counter( - {KAFKA_SESSION_RECORDING_EVENTS: 3, KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS: 1} - ) + "offset": 1993, + } @patch("posthog.kafka_client.client._KafkaProducer.produce") def test_recording_ingestion_can_write_to_blob_ingestion_topic_with_usual_size_limit(self, kafka_produce) -> None: @@ -1254,10 +1232,7 @@ def test_recording_ingestion_can_write_to_blob_ingestion_topic_with_usual_size_l self._send_session_recording_event(event_data=large_data_array) topic_counter = Counter([call[1]["topic"] for call in kafka_produce.call_args_list]) - # this fake data doesn't split, so we send one huge message to the item events topic - assert topic_counter == Counter( - {KAFKA_SESSION_RECORDING_EVENTS: 3, KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS: 1} - ) + assert topic_counter == Counter({KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS: 1}) @patch("posthog.kafka_client.client._KafkaProducer.produce") def test_recording_ingestion_can_write_to_blob_ingestion_topic(self, kafka_produce) -> None: @@ -1267,9 +1242,7 @@ def test_recording_ingestion_can_write_to_blob_ingestion_topic(self, kafka_produ self._send_session_recording_event(event_data=large_data_array) topic_counter = Counter([call[1]["topic"] for call in kafka_produce.call_args_list]) - assert topic_counter == Counter( - {KAFKA_SESSION_RECORDING_EVENTS: 3, KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS: 1} - ) + assert topic_counter == Counter({KAFKA_SESSION_RECORDING_SNAPSHOT_ITEM_EVENTS: 1}) @patch("posthog.kafka_client.client.SessionRecordingKafkaProducer") def test_create_session_recording_kafka_with_expected_hosts( @@ -1318,20 +1291,15 @@ def test_can_redirect_session_recordings_to_alternative_kafka( data = "example" session_id = "test_can_redirect_session_recordings_to_alternative_kafka" self._send_session_recording_event(event_data=data, session_id=session_id) - default_kafka_producer_mock.assert_called() + # session events don't get routed through the default kafka producer + default_kafka_producer_mock.assert_not_called() session_recording_producer_factory_mock.assert_called() - assert len(kafka_produce.call_args_list) == 2 + assert len(kafka_produce.call_args_list) == 1 call_one = kafka_produce.call_args_list[0][1] assert call_one["key"] == session_id - data_sent_to_default_kafka = json.loads(call_one["data"]["data"]) - assert data_sent_to_default_kafka["event"] == "$snapshot" - assert data_sent_to_default_kafka["properties"]["$snapshot_data"]["chunk_count"] == 1 - - call_two = kafka_produce.call_args_list[1][1] - assert call_two["key"] == session_id - data_sent_to_recording_kafka = json.loads(call_two["data"]["data"]) + data_sent_to_recording_kafka = json.loads(call_one["data"]["data"]) assert data_sent_to_recording_kafka["event"] == "$snapshot_items" assert len(data_sent_to_recording_kafka["properties"]["$snapshot_items"]) == 1 @@ -1388,11 +1356,11 @@ def test_quota_limits_ignored_if_disabled(self, kafka_produce) -> None: replace_limited_team_tokens(QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() + 10000}) replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() + 10000}) self._send_session_recording_event() - self.assertEqual(kafka_produce.call_count, 2) + self.assertEqual(kafka_produce.call_count, 1) @patch("posthog.kafka_client.client._KafkaProducer.produce") @pytest.mark.ee - def test_quota_limits(self, kafka_produce) -> None: + def test_quota_limits(self, kafka_produce: MagicMock) -> None: from ee.billing.quota_limiting import QuotaResource, replace_limited_team_tokens def _produce_events(): @@ -1413,11 +1381,18 @@ def _produce_events(): with self.settings(QUOTA_LIMITING_ENABLED=True): _produce_events() - self.assertEqual(kafka_produce.call_count, 4) + self.assertEqual( + [c[1]["topic"] for c in kafka_produce.call_args_list], + [ + "session_recording_snapshot_item_events_test", + "events_plugin_ingestion_test", + "events_plugin_ingestion_test", + ], + ) replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() + 10000}) _produce_events() - self.assertEqual(kafka_produce.call_count, 2) # Only the recording event + self.assertEqual(kafka_produce.call_count, 1) # Only the recording event replace_limited_team_tokens( QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() + 10000} @@ -1430,7 +1405,7 @@ def _produce_events(): ) replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() - 10000}) _produce_events() - self.assertEqual(kafka_produce.call_count, 4) # All events as limit-until timestamp is in the past + self.assertEqual(kafka_produce.call_count, 3) # All events as limit-until timestamp is in the past @patch("posthog.kafka_client.client._KafkaProducer.produce") def test_capture_historical_analytics_events(self, kafka_produce) -> None: diff --git a/posthog/api/test/test_persons_trends.py b/posthog/api/test/test_persons_trends.py index e5594673296c2..46e72c5651bec 100644 --- a/posthog/api/test/test_persons_trends.py +++ b/posthog/api/test/test_persons_trends.py @@ -5,7 +5,7 @@ from posthog.constants import ENTITY_ID, ENTITY_MATH, ENTITY_TYPE, TRENDS_CUMULATIVE from posthog.models import Action, ActionStep, Cohort, Organization -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -813,13 +813,13 @@ def test_trends_people_endpoint_includes_recordings(self): timestamp="2020-01-09T12:00:00Z", properties={"$session_id": "s1", "$window_id": "w1"}, ) - create_session_recording_events( - self.team.pk, - datetime(2020, 1, 9, 12), - "u1", - "s1", - use_recording_table=False, - use_replay_table=True, + timestamp = datetime(2020, 1, 9, 12) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="u1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) people = self.client.get( diff --git a/posthog/demo/legacy/data_generator.py b/posthog/demo/legacy/data_generator.py index 375805764a51b..65bdd350acc88 100644 --- a/posthog/demo/legacy/data_generator.py +++ b/posthog/demo/legacy/data_generator.py @@ -3,7 +3,7 @@ from posthog.models import Person, PersonDistinctId, Team from posthog.models.utils import UUIDT -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary class DataGenerator: @@ -72,13 +72,15 @@ def bulk_import_events(self): for event_data in self.events: create_event(**event_data, team=self.team, event_uuid=uuid4()) for data in self.snapshots: - create_session_recording_events( + timestamp = data["timestamp"] + distinct_id = data["distinct_id"] + session_id = data["session_id"] + produce_replay_summary( team_id=self.team.pk, - timestamp=data["timestamp"], - distinct_id=data["distinct_id"], - session_id=data["session_id"], - window_id=data["window_id"], - snapshots=[data["snapshot_data"]], + session_id=session_id, + distinct_id=distinct_id, + first_timestamp=timestamp, + last_timestamp=timestamp, ) def add_if_not_contained(self, array, value): diff --git a/posthog/helpers/tests/test_session_recording_helpers.py b/posthog/helpers/tests/test_session_recording_helpers.py deleted file mode 100644 index ee6a3c6ccada2..0000000000000 --- a/posthog/helpers/tests/test_session_recording_helpers.py +++ /dev/null @@ -1,762 +0,0 @@ -import json -import math -import random -import string -from datetime import datetime -from typing import Any, List, Tuple, cast - -import pytest -from pytest_mock import MockerFixture - -from posthog.session_recordings.session_recording_helpers import ( - RRWEB_MAP_EVENT_TYPE, - SessionRecordingEventSummary, - SnapshotData, - SnapshotDataTaggedWithWindowId, - decompress_chunked_snapshot_data, - get_events_summary_from_snapshot_data, - is_active_event, - legacy_preprocess_session_recording_events_for_clickhouse, - preprocess_replay_events_for_blob_ingestion, - split_replay_events, -) - -MILLISECOND_TIMESTAMP = round(datetime(2019, 1, 1).timestamp() * 1000) - - -def create_activity_data(timestamp: datetime, is_active: bool): - return SessionRecordingEventSummary( - timestamp=round(timestamp.timestamp() * 1000), - type=3, - data=dict(source=1 if is_active else -1), - ) - - -def mock_capture_flow(events: List[dict], max_size_bytes=512 * 1024) -> Tuple[List[dict], List[dict]]: - """ - Returns the legacy events and the new flow ones - """ - replay_events, other_events = split_replay_events(events) - legacy_replay_events = legacy_preprocess_session_recording_events_for_clickhouse( - replay_events, chunk_size=max_size_bytes - ) - new_replay_events = preprocess_replay_events_for_blob_ingestion(replay_events, max_size_bytes=max_size_bytes) - - return legacy_replay_events + other_events, new_replay_events + other_events - - -def test_preprocess_with_no_recordings(): - events = [{"event": "$pageview"}, {"event": "$pageleave"}] - assert mock_capture_flow(events)[0] == events - - -def test_preprocess_recording_event_groups_snapshots_split_by_session_and_window_id(): - events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$snapshot_data": {"type": 2, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$snapshot_data": {"type": 1, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "5678", - "$window_id": "1", - "$snapshot_data": {"type": 1, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "5678", - "$window_id": "2", - "$snapshot_data": {"type": 1, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - ] - - preprocessed, _ = mock_capture_flow(events) - assert preprocessed != events - assert len(preprocessed) == 3 - expected_session_ids = ["1234", "5678", "5678"] - expected_window_ids = [None, "1", "2"] - for index, result in enumerate(preprocessed): - assert result["event"] == "$snapshot" - assert result["properties"]["$session_id"] == expected_session_ids[index] - assert result["properties"].get("$window_id") == expected_window_ids[index] - assert result["properties"]["distinct_id"] == "abc123" - assert "chunk_id" in result["properties"]["$snapshot_data"] - assert result["event"] == "$snapshot" - - # it does not rechunk already chunked events - assert mock_capture_flow(preprocessed)[0] == preprocessed - - -def test_compression_and_grouping(raw_snapshot_events, mocker: MockerFixture): - mocker.patch("posthog.models.utils.UUIDT", return_value="0178495e-8521-0000-8e1c-2652fa57099b") - mocker.patch("time.time", return_value=0) - - assert list(mock_capture_flow(raw_snapshot_events)[0]) == [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": { - "chunk_id": "0178495e-8521-0000-8e1c-2652fa57099b", - "chunk_index": 0, - "chunk_count": 1, - "compression": "gzip-base64", - "data": "H4sIAAAAAAAC//v/L5qhmkGJoYShkqGAIRXIsmJQYDBi0AGSINFMhlygaDGQlQhkFUDlDRlMGUwYzBiMGQyA0AJMQmAtWCemicYUmBjLAAABQ+l7pgAAAA==", - "has_full_snapshot": True, - "events_summary": [ - {"timestamp": MILLISECOND_TIMESTAMP, "type": 2, "data": {}}, - {"timestamp": MILLISECOND_TIMESTAMP, "type": 3, "data": {}}, - ], - }, - "distinct_id": "abc123", - }, - } - ] - - -def test_decompression_results_in_same_data(raw_snapshot_events): - assert len(list(mock_capture_flow(raw_snapshot_events, 1000)[0])) == 1 - assert compress_decompress_and_extract(raw_snapshot_events, 1000) == [ - raw_snapshot_events[0]["properties"]["$snapshot_data"], - raw_snapshot_events[1]["properties"]["$snapshot_data"], - ] - assert len(list(mock_capture_flow(raw_snapshot_events, 100)[0])) == 2 - assert compress_decompress_and_extract(raw_snapshot_events, 100) == [ - raw_snapshot_events[0]["properties"]["$snapshot_data"], - raw_snapshot_events[1]["properties"]["$snapshot_data"], - ] - - -def test_has_full_snapshot_property(raw_snapshot_events): - compressed = list(mock_capture_flow(raw_snapshot_events)[0]) - assert len(compressed) == 1 - assert compressed[0]["properties"]["$snapshot_data"]["has_full_snapshot"] - - raw_snapshot_events[0]["properties"]["$snapshot_data"]["type"] = 0 - compressed = list(mock_capture_flow(raw_snapshot_events)[0]) - assert len(compressed) == 1 - assert not compressed[0]["properties"]["$snapshot_data"]["has_full_snapshot"] - - -def test_decompress_uncompressed_events_returns_unmodified_events(raw_snapshot_events): - snapshot_data_tagged_with_window_id = [] - raw_snapshot_data = [] - for event in raw_snapshot_events: - snapshot_data_tagged_with_window_id.append( - SnapshotDataTaggedWithWindowId(snapshot_data=event["properties"]["$snapshot_data"], window_id="1") - ) - raw_snapshot_data.append(event["properties"]["$snapshot_data"]) - - assert ( - decompress_chunked_snapshot_data(snapshot_data_tagged_with_window_id)["snapshot_data_by_window_id"]["1"] - == raw_snapshot_data - ) - - -def test_decompress_ignores_if_not_enough_chunks(raw_snapshot_events): - raw_snapshot_data = [event["properties"]["$snapshot_data"] for event in raw_snapshot_events] - snapshot_data_list = [ - event["properties"]["$snapshot_data"] for event in mock_capture_flow(raw_snapshot_events, 100)[0] - ] - window_id = "abc123" - snapshot_list = [] - for snapshot_data in snapshot_data_list: - snapshot_list.append(SnapshotDataTaggedWithWindowId(window_id=window_id, snapshot_data=snapshot_data)) - - snapshot_list.append( - SnapshotDataTaggedWithWindowId( - snapshot_data={ - "chunk_id": "unique_id", - "chunk_index": 1, - "chunk_count": 2, - "data": {}, - "compression": "gzip", - "has_full_snapshot": False, - }, - window_id=window_id, - ) - ) - - assert decompress_chunked_snapshot_data(snapshot_list)["snapshot_data_by_window_id"][window_id] == raw_snapshot_data - - -def test_decompress_deduplicates_if_duplicate_chunks(raw_snapshot_events): - raw_snapshot_data = [event["properties"]["$snapshot_data"] for event in raw_snapshot_events] - snapshot_data_list = [ - event["properties"]["$snapshot_data"] for event in mock_capture_flow(raw_snapshot_events, 10)[0] - ] # makes 12 chunks - # take the first four chunks twice, then the remainder, and then again the first four chunks twice from snapshot_data_list - snapshot_data_list = ( - snapshot_data_list[:4] - + snapshot_data_list[:4] - + snapshot_data_list[4:] - + snapshot_data_list[:4] - + snapshot_data_list[:4] - ) - - window_id = "abc123" - snapshot_list = [] - for snapshot_data in snapshot_data_list: - snapshot_list.append(SnapshotDataTaggedWithWindowId(window_id=window_id, snapshot_data=snapshot_data)) - - assert decompress_chunked_snapshot_data(snapshot_list)["snapshot_data_by_window_id"][window_id] == raw_snapshot_data - - -def test_decompress_ignores_if_too_few_chunks_even_after_deduplication(raw_snapshot_events): - snapshot_data_list = [ - event["properties"]["$snapshot_data"] for event in mock_capture_flow(raw_snapshot_events, 20)[0] - ] # makes 6 chunks - - assert len(snapshot_data_list) == 6 - # take the first four chunks four times, then not quite all the remainder - # leaves more than 12 chunks in total, but not enough to decompress - snapshot_data_list = ( - snapshot_data_list[:2] - + snapshot_data_list[:2] - + snapshot_data_list[:2] - + snapshot_data_list[:2] - + snapshot_data_list[4:-1] - ) - - window_id = "abc123" - snapshot_list = [] - for snapshot_data in snapshot_data_list: - snapshot_list.append(SnapshotDataTaggedWithWindowId(window_id=window_id, snapshot_data=snapshot_data)) - - assert decompress_chunked_snapshot_data(snapshot_list)["snapshot_data_by_window_id"][window_id] == [] - - -def test_paginate_decompression(chunked_and_compressed_snapshot_events): - snapshot_data = [ - SnapshotDataTaggedWithWindowId( - snapshot_data=event["properties"]["$snapshot_data"], window_id=event["properties"].get("$window_id") - ) - for event in chunked_and_compressed_snapshot_events - ] - - # Get the first chunk - paginated_events = decompress_chunked_snapshot_data(snapshot_data, 1, 0) - assert paginated_events["has_next"] is True - assert cast(SnapshotData, paginated_events["snapshot_data_by_window_id"][None][0])["type"] == 4 - assert len(paginated_events["snapshot_data_by_window_id"][None]) == 2 # 2 events in a chunk - - # Get the second chunk - paginated_events = decompress_chunked_snapshot_data(snapshot_data, 1, 1) - assert paginated_events["has_next"] is False - assert cast(SnapshotData, paginated_events["snapshot_data_by_window_id"]["1"][0])["type"] == 3 - assert len(paginated_events["snapshot_data_by_window_id"]["1"]) == 2 # 2 events in a chunk - - # Limit exceeds the length - paginated_events = decompress_chunked_snapshot_data(snapshot_data, 10, 0) - assert paginated_events["has_next"] is False - assert len(paginated_events["snapshot_data_by_window_id"]["1"]) == 2 - assert len(paginated_events["snapshot_data_by_window_id"][None]) == 2 - - # Offset exceeds the length - paginated_events = decompress_chunked_snapshot_data(snapshot_data, 10, 2) - assert paginated_events["has_next"] is False - assert paginated_events["snapshot_data_by_window_id"] == {} - - # Non sequential snapshots - snapshot_data = snapshot_data[-3:] + snapshot_data[0:-3] - paginated_events = decompress_chunked_snapshot_data(snapshot_data, 10, 0) - assert paginated_events["has_next"] is False - assert len(paginated_events["snapshot_data_by_window_id"]["1"]) == 2 - assert len(paginated_events["snapshot_data_by_window_id"][None]) == 2 - - # No limit or offset provided - paginated_events = decompress_chunked_snapshot_data(snapshot_data) - assert paginated_events["has_next"] is False - assert len(paginated_events["snapshot_data_by_window_id"]["1"]) == 2 - assert len(paginated_events["snapshot_data_by_window_id"][None]) == 2 - - -def test_decompress_empty_list(chunked_and_compressed_snapshot_events): - paginated_events = decompress_chunked_snapshot_data([]) - assert paginated_events["has_next"] is False - assert paginated_events["snapshot_data_by_window_id"] == {} - - -def test_decompress_data_returning_only_activity_info(chunked_and_compressed_snapshot_events): - snapshot_data = [ - SnapshotDataTaggedWithWindowId( - snapshot_data=event["properties"]["$snapshot_data"], window_id=event["properties"].get("$window_id") - ) - for event in chunked_and_compressed_snapshot_events - ] - paginated_events = decompress_chunked_snapshot_data(snapshot_data, return_only_activity_data=True) - - assert paginated_events["snapshot_data_by_window_id"] == { - None: [ - {"timestamp": 1546300800000, "type": 4, "data": {}}, - {"timestamp": 1546300800000, "type": 2, "data": {}}, - ], - "1": [ - {"timestamp": 1546300800000, "type": 3, "data": {}}, - {"timestamp": 1546300800000, "type": 3, "data": {"source": 2}}, - ], - } - - -def test_get_events_summary_from_snapshot_data(): - timestamp = round(datetime.now().timestamp() * 1000) - - snapshot_events: List[SnapshotData | None] = [ - # ignore malformed events - {"type": 2, "foo": "bar"}, - # ignore other props - {"type": 2, "timestamp": timestamp, "foo": "bar"}, - # include standard properties - {"type": 1, "timestamp": timestamp, "data": {"source": 3}}, - # Payload as list when we expect a dict - {"type": 1, "timestamp": timestamp, "data": {"source": 3, "payload": [1, 2, 3]}}, - # include only allowed values - { - "type": 1, - "timestamp": timestamp, - "data": { - # Large values we dont want - "node": {}, - "text": "long-useless-text", - # Standard core values we want - "source": 3, - "type": 1, - # Values for initial render meta event - "href": "https://app.posthog.com/events?foo=bar", - "width": 2056, - "height": 1120, - # Special case for custom pageview events - "tag": "$pageview", - "plugin": "rrweb/console@1", - "payload": { - "href": "https://app.posthog.com/events?eventFilter=", # from pageview - "level": "log", # from console plugin - # random - "dont-want": "this", - "or-this": {"foo": "bar"}, - }, - }, - }, - # payload has iso string timestamp instead of number and is out of order by timestamp sort - # in https://posthog.sentry.io/issues/4089255349/?project=1899813&referrer=slack we saw a client - # send this event, which caused the backend sorting to fail because we treat the rrweb timestamp - # as if it is always a number - { - "type": 1, - "timestamp": "1987-04-28T17:17:17.590Z", - "data": {"source": 3}, - }, - # safely ignore string timestamps that aren't timestamps - { - "type": 1, - "timestamp": "it was about a hundred years ago, that I remember this happening", - "data": {"source": 3}, - }, - # we can see malformed packets - {"data": {}}, - {}, - None, - ] - - assert get_events_summary_from_snapshot_data(snapshot_events) == [ - {"data": {"source": 3}, "timestamp": 546628637590, "type": 1}, - {"timestamp": timestamp, "type": 2, "data": {}}, - {"timestamp": timestamp, "type": 1, "data": {"source": 3}}, - {"timestamp": timestamp, "type": 1, "data": {"source": 3}}, - { - "timestamp": timestamp, - "type": 1, - "data": { - "source": 3, - "type": 1, - "href": "https://app.posthog.com/events?foo=bar", - "width": 2056, - "height": 1120, - "tag": "$pageview", - "plugin": "rrweb/console@1", - "payload": { - "href": "https://app.posthog.com/events?eventFilter=", - "level": "log", - }, - }, - }, - ] - - -@pytest.fixture -def raw_snapshot_events(): - return [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 2, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - ] - - -@pytest.fixture -def chunked_and_compressed_snapshot_events(): - chunk_1_events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$snapshot_data": {"type": 4, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$snapshot_data": {"type": 2, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - ] - chunk_2_events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": { - "type": 3, - "timestamp": MILLISECOND_TIMESTAMP, - "data": {"source": 2}, - }, - "distinct_id": "abc123", - }, - }, - ] - return list(mock_capture_flow(chunk_1_events)[0]) + list(mock_capture_flow(chunk_2_events)[0]) - - -def compress_decompress_and_extract(events, chunk_size): - snapshot_data_list = [event["properties"]["$snapshot_data"] for event in mock_capture_flow(events, chunk_size)[0]] - window_id = "abc123" - snapshot_list = [] - for snapshot_data in snapshot_data_list: - snapshot_list.append(SnapshotDataTaggedWithWindowId(window_id=window_id, snapshot_data=snapshot_data)) - - return decompress_chunked_snapshot_data(snapshot_list)["snapshot_data_by_window_id"][window_id] - - -# def test_get_events_summary_from_snapshot_data(): -# timestamp = round(datetime.now().timestamp() * 1000) -# snapshot_events = [ -# {"type": 2, "foo": "bar", "timestamp": timestamp}, -# {"type": 1, "foo": "bar", "timestamp": timestamp}, -# {"type": 1, "foo": "bar", "timestamp": timestamp, "data": {"source": 3}}, -# ] - -# assert get_events_summary_from_snapshot_data(snapshot_events) == [ -# {"timestamp": timestamp, "type": 2, "data": {}}, -# {"timestamp": timestamp, "type": 1, "data": {}}, -# {"timestamp": timestamp, "type": 1, "data": {"source": 3}}, -# ] - - -def test_is_active_event(): - timestamp = round(datetime.now().timestamp() * 1000) - assert is_active_event({"timestamp": timestamp, "type": 3, "data": {}}) is False - assert is_active_event({"timestamp": timestamp, "type": 2, "data": {"source": 3}}) is False - assert is_active_event({"timestamp": timestamp, "type": 3, "data": {"source": 3}}) is True - - -def test_new_ingestion(raw_snapshot_events, mocker: MockerFixture): - mocker.patch("time.time", return_value=0) - - big_payload = "".join(random.choices(string.ascii_uppercase + string.digits, k=1025)) - - events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": { - "type": RRWEB_MAP_EVENT_TYPE.FullSnapshot, - "timestamp": 123, - "something": big_payload, - }, - "distinct_id": "abc123", - }, - }, - ] - - assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ - { - "event": "$snapshot_items", - "properties": { - "distinct_id": "abc123", - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [ - {"type": 3, "timestamp": 1546300800000}, - {"type": 3, "timestamp": 1546300800000}, - { - "type": 2, - "timestamp": 123, - "something": big_payload, - }, - ], - }, - } - ] - - -def test_new_ingestion_large_full_snapshot_is_separated(raw_snapshot_events, mocker: MockerFixture): - mocker.patch("time.time", return_value=0) - - big_payload = "".join(random.choices(string.ascii_uppercase + string.digits, k=10000)) - - events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, - "distinct_id": "abc123", - }, - }, - ] + [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": { - "type": RRWEB_MAP_EVENT_TYPE.FullSnapshot, - "timestamp": 123, - "something": big_payload, - }, - "distinct_id": "abc123", - }, - }, - ] - - assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ - { - "event": "$snapshot_items", - "properties": { - "distinct_id": "abc123", - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [ - { - "type": 2, - "timestamp": 123, - "something": big_payload, - } - ], - }, - }, - { - "event": "$snapshot_items", - "properties": { - "distinct_id": "abc123", - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [{"type": 3, "timestamp": 1546300800000}, {"type": 3, "timestamp": 1546300800000}], - }, - }, - ] - - -def test_new_ingestion_large_non_full_snapshots_are_separated(raw_snapshot_events, mocker: MockerFixture): - mocker.patch("posthog.models.utils.UUIDT", return_value="0178495e-8521-0000-8e1c-2652fa57099b") - mocker.patch("time.time", return_value=0) - - almost_too_big_payloads = [ - "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), - "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), - ] - - events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 7, "timestamp": 234, "something": almost_too_big_payloads[0]}, - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_data": {"type": 8, "timestamp": 123, "something": almost_too_big_payloads[1]}, - "distinct_id": "abc123", - }, - }, - ] - assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ - { - "event": "$snapshot_items", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [{"type": 7, "timestamp": 234, "something": almost_too_big_payloads[0]}], - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot_items", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [{"type": 8, "timestamp": 123, "something": almost_too_big_payloads[1]}], - "distinct_id": "abc123", - }, - }, - ] - - -def test_new_ingestion_groups_using_snapshot_bytes_if_possible(raw_snapshot_events, mocker: MockerFixture): - mocker.patch("posthog.models.utils.UUIDT", return_value="0178495e-8521-0000-8e1c-2652fa57099b") - mocker.patch("time.time", return_value=0) - - almost_too_big_event = { - "type": 7, - "timestamp": 234, - "something": "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), - } - - small_event = { - "type": 7, - "timestamp": 234, - "something": "small", - } - - events: List[Any] = [ - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_bytes": len(json.dumps([small_event, small_event])), - "$snapshot_data": [small_event, small_event], - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_bytes": len(json.dumps([almost_too_big_event])), - "$snapshot_data": [almost_too_big_event], - "distinct_id": "abc123", - }, - }, - { - "event": "$snapshot", - "properties": { - "$session_id": "1234", - "$window_id": "1", - "$snapshot_bytes": len(json.dumps([small_event, small_event, small_event])), - "$snapshot_data": [small_event, small_event, small_event], - "distinct_id": "abc123", - }, - }, - ] - - assert [event["properties"]["$snapshot_bytes"] for event in events] == [106, 1072, 159] - - space_with_headroom = math.ceil((106 + 1072 + 50) * 1.05) - assert list(mock_capture_flow(events, max_size_bytes=space_with_headroom)[1]) == [ - { - "event": "$snapshot_items", - "properties": { - "distinct_id": "abc123", - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [ - small_event, - small_event, - almost_too_big_event, - ], - }, - }, - { - "event": "$snapshot_items", - "properties": { - "distinct_id": "abc123", - "$session_id": "1234", - "$window_id": "1", - "$snapshot_items": [small_event, small_event, small_event], - }, - }, - ] diff --git a/posthog/queries/funnels/test/test_funnel_persons.py b/posthog/queries/funnels/test/test_funnel_persons.py index fa8d5cbeeef7e..8f8ed2b638f67 100644 --- a/posthog/queries/funnels/test/test_funnel_persons.py +++ b/posthog/queries/funnels/test/test_funnel_persons.py @@ -9,7 +9,7 @@ from posthog.models.event.util import bulk_create_events from posthog.models.person.util import bulk_create_persons from posthog.queries.funnels.funnel_persons import ClickhouseFunnelActors -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -443,7 +443,14 @@ def test_funnel_person_recordings(self): properties={"$session_id": "s2", "$window_id": "w2"}, event_uuid="21111111-1111-1111-1111-111111111111", ) - create_session_recording_events(self.team.pk, datetime(2021, 1, 3, 0, 0, 0), "user_1", "s2") + timestamp = datetime(2021, 1, 3, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s2", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) # First event, but no recording filter = Filter( diff --git a/posthog/queries/funnels/test/test_funnel_strict_persons.py b/posthog/queries/funnels/test/test_funnel_strict_persons.py index a1abbbb03139a..9c9a304a59e8f 100644 --- a/posthog/queries/funnels/test/test_funnel_strict_persons.py +++ b/posthog/queries/funnels/test/test_funnel_strict_persons.py @@ -7,7 +7,7 @@ from posthog.constants import INSIGHT_FUNNELS from posthog.models.filters import Filter from posthog.queries.funnels.funnel_strict_persons import ClickhouseFunnelStrictActors -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -154,11 +154,13 @@ def test_strict_funnel_person_recordings(self): properties={"$session_id": "s2", "$window_id": "w2"}, event_uuid="21111111-1111-1111-1111-111111111111", ) - create_session_recording_events( - self.team.pk, - datetime(2021, 1, 3, 0, 0, 0), - "user_1", - "s2", + timestamp = datetime(2021, 1, 3, 0, 0, 0) + produce_replay_summary( + team_id=self.team.pk, + session_id="s2", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, ) # First event, but no recording diff --git a/posthog/queries/funnels/test/test_funnel_trends_persons.py b/posthog/queries/funnels/test/test_funnel_trends_persons.py index 5f0ec37832137..ee75bfb025719 100644 --- a/posthog/queries/funnels/test/test_funnel_trends_persons.py +++ b/posthog/queries/funnels/test/test_funnel_trends_persons.py @@ -3,7 +3,7 @@ from posthog.constants import INSIGHT_FUNNELS, FunnelVizType from posthog.models.filters import Filter from posthog.queries.funnels.funnel_trends_persons import ClickhouseFunnelTrendsActors -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import APIBaseTest, ClickhouseTestMixin, snapshot_clickhouse_queries from posthog.test.test_journeys import journeys_for @@ -35,7 +35,14 @@ def test_funnel_trend_persons_returns_recordings(self): }, self.team, ) - create_session_recording_events(self.team.pk, datetime(2021, 5, 1), "user_one", "s1b") + timestamp = datetime(2021, 5, 1) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1b", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) filter = Filter(data={"funnel_to_step": 1, **filter_data}) _, results, _ = ClickhouseFunnelTrendsActors(filter, self.team).get_actors() @@ -55,7 +62,14 @@ def test_funnel_trend_persons_with_no_to_step(self): self.team, ) # the session recording can start a little before the events in the funnel - create_session_recording_events(self.team.pk, datetime(2021, 5, 1) - timedelta(hours=12), "user_one", "s1c") + timestamp = datetime(2021, 5, 1) - timedelta(hours=12) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1c", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) filter = Filter(data=filter_data) _, results, _ = ClickhouseFunnelTrendsActors(filter, self.team).get_actors() @@ -72,7 +86,14 @@ def test_funnel_trend_persons_with_drop_off(self): }, self.team, ) - create_session_recording_events(self.team.pk, datetime(2021, 5, 1), "user_one", "s1a") + timestamp = datetime(2021, 5, 1) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1a", + distinct_id="user_one", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) filter = Filter(data={**filter_data, "drop_off": True}) _, results, _ = ClickhouseFunnelTrendsActors(filter, self.team).get_actors() diff --git a/posthog/queries/funnels/test/test_funnel_unordered_persons.py b/posthog/queries/funnels/test/test_funnel_unordered_persons.py index d34c51e4a1706..673dee6d30826 100644 --- a/posthog/queries/funnels/test/test_funnel_unordered_persons.py +++ b/posthog/queries/funnels/test/test_funnel_unordered_persons.py @@ -7,7 +7,7 @@ from posthog.constants import INSIGHT_FUNNELS from posthog.models.filters import Filter from posthog.queries.funnels.funnel_unordered_persons import ClickhouseFunnelUnorderedActors -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -160,7 +160,14 @@ def test_unordered_funnel_does_not_return_recordings(self): event_uuid="11111111-1111-1111-1111-111111111111", ) - create_session_recording_events(self.team.pk, timezone.now() + timedelta(days=1), "user_1", "s1") + timestamp = timezone.now() + timedelta(days=1) + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="user_1", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) filter = Filter( data={ diff --git a/posthog/queries/trends/test/test_person.py b/posthog/queries/trends/test/test_person.py index f68a4ed13b9bd..bfd18b6ed8de8 100644 --- a/posthog/queries/trends/test/test_person.py +++ b/posthog/queries/trends/test/test_person.py @@ -12,7 +12,7 @@ from posthog.models.group.util import create_group from posthog.models.group_type_mapping import GroupTypeMapping from posthog.queries.trends.trends_actors import TrendsActors -from posthog.session_recordings.test.test_factory import create_session_recording_events +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -39,7 +39,14 @@ def test_person_query_includes_recording_events(self): timestamp=timezone.now(), properties={"$session_id": "s2", "$window_id": "w2"}, ) # No associated recording, so not included - create_session_recording_events(self.team.pk, timezone.now(), "u1", "s1") + timestamp = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="u1", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) _create_event( event="pageview", @@ -108,7 +115,14 @@ def test_person_query_does_not_include_recording_events_if_flag_not_set(self): def test_group_query_includes_recording_events(self): GroupTypeMapping.objects.create(team=self.team, group_type="organization", group_type_index=0) create_group(team_id=self.team.pk, group_type_index=0, group_key="bla", properties={}) - create_session_recording_events(self.team.pk, timezone.now(), "u1", "s1") + timestamp = timezone.now() + produce_replay_summary( + team_id=self.team.pk, + session_id="s1", + distinct_id="u1", + first_timestamp=timestamp, + last_timestamp=timestamp, + ) _create_event( event="pageview", distinct_id="u1", team=self.team, timestamp=timezone.now(), properties={"$group_0": "bla"} diff --git a/posthog/session_recordings/models/metadata.py b/posthog/session_recordings/models/metadata.py index 0f1e2e5a3731c..98359a09f30fe 100644 --- a/posthog/session_recordings/models/metadata.py +++ b/posthog/session_recordings/models/metadata.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, List, Optional, TypedDict, Union +from typing import Dict, List, Optional, TypedDict, Union SnapshotData = Dict WindowId = Optional[str] @@ -25,15 +25,6 @@ class SessionRecordingEventSummary(TypedDict): data: Dict[str, Union[int, str]] -class SessionRecordingEvent(TypedDict): - timestamp: datetime - distinct_id: str - session_id: str - window_id: str - snapshot_data: Dict[str, Any] - events_summary: List[SessionRecordingEventSummary] - - # NOTE: MatchingSessionRecordingEvent is a minimal version of full events that is used to display events matching a filter on the frontend class MatchingSessionRecordingEvent(TypedDict): uuid: str diff --git a/posthog/session_recordings/models/session_recording.py b/posthog/session_recordings/models/session_recording.py index ec259fbaf9143..b3b09a03d0b74 100644 --- a/posthog/session_recordings/models/session_recording.py +++ b/posthog/session_recordings/models/session_recording.py @@ -1,20 +1,19 @@ from typing import Any, List, Optional, Literal +from django.conf import settings from django.db import models -from django.dispatch import receiver from posthog.celery import ee_persist_single_recording from posthog.models.person.person import Person +from posthog.models.signals import mutable_receiver +from posthog.models.team.team import Team +from posthog.models.utils import UUIDModel from posthog.session_recordings.models.metadata import ( - DecompressedRecordingData, RecordingMatchingEvents, RecordingMetadata, ) from posthog.session_recordings.models.session_recording_event import SessionRecordingViewed -from posthog.models.team.team import Team -from posthog.models.utils import UUIDModel from posthog.session_recordings.queries.session_replay_events import SessionReplayEvents -from django.conf import settings class SessionRecording(UUIDModel): @@ -61,7 +60,6 @@ class Meta: # Metadata can be loaded from Clickhouse or S3 _metadata: Optional[RecordingMetadata] = None - _snapshots: Optional[DecompressedRecordingData] = None def load_metadata(self) -> bool: if self._metadata: @@ -95,55 +93,6 @@ def load_metadata(self) -> bool: return True - def load_snapshots(self, limit=20, offset=0) -> None: - from posthog.session_recordings.queries.session_recording_events import SessionRecordingEvents - - if self._snapshots: - return - - if self.object_storage_path: - self.load_object_data() - else: - snapshots = SessionRecordingEvents( - team=self.team, session_recording_id=self.session_id, recording_start_time=self.start_time - ).get_snapshots(limit, offset) - - self._snapshots = snapshots - - def load_object_data(self) -> None: - """ - This is only called in the to-be deprecated v1 of session recordings snapshot API - """ - try: - from ee.session_recordings.session_recording_extensions import load_persisted_recording - except ImportError: - load_persisted_recording = lambda *args: None - - data = load_persisted_recording(self) - - if not data: - return - - if data.get("version", None) == "2022-12-22": - self._snapshots = { - "has_next": False, - "snapshot_data_by_window_id": data["snapshot_data_by_window_id"], - } - elif data.get("version", None) == "2023-08-01": - raise NotImplementedError("Storage version 2023-08-01 will never be supported in this code path") - else: - # unknown version - return - - # S3 / Clickhouse backed fields - @property - def snapshot_data_by_window_id(self): - return self._snapshots["snapshot_data_by_window_id"] if self._snapshots else None - - @property - def can_load_more_snapshots(self): - return self._snapshots["has_next"] if self._snapshots else False - @property def storage(self): if self._state.adding: @@ -242,7 +191,7 @@ def set_start_url_from_urls(self, urls: Optional[List[str]] = None, first_url: O self.start_url = url.split("?")[0][:512] if url else None -@receiver(models.signals.post_save, sender=SessionRecording) +@mutable_receiver(models.signals.post_save, sender=SessionRecording) def attempt_persist_recording(sender, instance: SessionRecording, created: bool, **kwargs): if created: ee_persist_single_recording.delay(instance.session_id, instance.team_id) diff --git a/posthog/session_recordings/queries/session_recording_events.py b/posthog/session_recordings/queries/session_recording_events.py deleted file mode 100644 index 826fb2a770ab0..0000000000000 --- a/posthog/session_recordings/queries/session_recording_events.py +++ /dev/null @@ -1,87 +0,0 @@ -import json -from datetime import datetime -from typing import Dict, List, Optional, Tuple - -from posthog.client import sync_execute -from posthog.models import Team -from posthog.session_recordings.models.metadata import ( - DecompressedRecordingData, - SessionRecordingEvent, - SnapshotDataTaggedWithWindowId, -) -from posthog.session_recordings.session_recording_helpers import ( - decompress_chunked_snapshot_data, -) - - -class SessionRecordingEvents: - _session_recording_id: str - _recording_start_time: Optional[datetime] - _team: Team - - def __init__(self, session_recording_id: str, team: Team, recording_start_time: Optional[datetime] = None) -> None: - self._session_recording_id = session_recording_id - self._team = team - self._recording_start_time = recording_start_time - - _recording_snapshot_query = """ - SELECT {fields} - FROM session_recording_events - PREWHERE - team_id = %(team_id)s - AND session_id = %(session_id)s - {date_clause} - ORDER BY timestamp - {limit_param} - """ - - def _get_recording_snapshot_date_clause(self) -> Tuple[str, Dict]: - if self._recording_start_time: - # If we can, we want to limit the time range being queried. - # Theoretically, we shouldn't have to look before the recording start time, - # but until we straighten out the recording start time logic, we should have a buffer - return ( - """ - AND toTimeZone(toDateTime(timestamp, 'UTC'), %(timezone)s) >= toDateTime(%(start_time)s, %(timezone)s) - INTERVAL 1 DAY - AND toTimeZone(toDateTime(timestamp, 'UTC'), %(timezone)s) <= toDateTime(%(start_time)s, %(timezone)s) + INTERVAL 2 DAY - """, - {"start_time": self._recording_start_time, "timezone": self._team.timezone}, - ) - return ("", {}) - - def _query_recording_snapshots(self, include_snapshots=False) -> List[SessionRecordingEvent]: - fields = ["session_id", "window_id", "distinct_id", "timestamp", "events_summary"] - if include_snapshots: - fields.append("snapshot_data") - - date_clause, date_clause_params = self._get_recording_snapshot_date_clause() - query = self._recording_snapshot_query.format(date_clause=date_clause, fields=", ".join(fields), limit_param="") - - response = sync_execute( - query, {"team_id": self._team.id, "session_id": self._session_recording_id, **date_clause_params} - ) - - return [ - SessionRecordingEvent( - session_id=columns[0], - window_id=columns[1], - distinct_id=columns[2], - timestamp=columns[3], - events_summary=[json.loads(x) for x in columns[4]] if columns[4] else [], - snapshot_data=json.loads(columns[5]) if len(columns) > 5 else None, - ) - for columns in response - ] - - def get_snapshots(self, limit, offset) -> Optional[DecompressedRecordingData]: - all_snapshots = [ - SnapshotDataTaggedWithWindowId( - window_id=recording_snapshot["window_id"], snapshot_data=recording_snapshot["snapshot_data"] - ) - for recording_snapshot in self._query_recording_snapshots(include_snapshots=True) - ] - decompressed = decompress_chunked_snapshot_data(all_snapshots, limit, offset) - - if decompressed["snapshot_data_by_window_id"] == {}: - return None - return decompressed diff --git a/posthog/session_recordings/queries/session_recording_list_from_replay_summary.py b/posthog/session_recordings/queries/session_recording_list_from_replay_summary.py index ba3b590204825..924b3481a5b74 100644 --- a/posthog/session_recordings/queries/session_recording_list_from_replay_summary.py +++ b/posthog/session_recordings/queries/session_recording_list_from_replay_summary.py @@ -3,22 +3,18 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Literal, NamedTuple, Tuple, Union -from django.conf import settings - from posthog.client import sync_execute -from posthog.cloud_utils import is_cloud -from posthog.constants import TREND_FILTER_TYPE_ACTIONS, AvailableFeature, PropertyOperatorType -from posthog.models import Entity +from posthog.constants import TREND_FILTER_TYPE_ACTIONS, PropertyOperatorType +from posthog.models import Entity, Team from posthog.models.action.util import format_entity_filter from posthog.models.filters.mixins.utils import cached_property from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter -from posthog.models.instance_setting import get_instance_setting from posthog.models.property import PropertyGroup from posthog.models.property.util import parse_prop_grouped_clauses from posthog.models.team import PersonOnEventsMode -from posthog.models.team.team import Team from posthog.queries.event_query import EventQuery from posthog.queries.util import PersonPropertiesMode +from posthog.session_recordings.queries.session_replay_events import ttl_days @dataclasses.dataclass(frozen=True) @@ -70,22 +66,6 @@ def _get_filter_by_provided_session_ids_clause( return f'AND "{column_name}" in %(session_ids)s', {"session_ids": recording_filters.session_ids} -def ttl_days(team: Team) -> int: - ttl_days = (get_instance_setting("RECORDINGS_TTL_WEEKS") or 3) * 7 - if is_cloud(): - # NOTE: We use Playlists as a proxy to see if they are subbed to Recordings - is_paid = team.organization.is_feature_available(AvailableFeature.RECORDINGS_PLAYLISTS) - ttl_days = settings.REPLAY_RETENTION_DAYS_MAX if is_paid else settings.REPLAY_RETENTION_DAYS_MIN - - # NOTE: The date we started reliably ingested data to blob storage - days_since_blob_ingestion = (datetime.now() - datetime(2023, 8, 1)).days - - if days_since_blob_ingestion < ttl_days: - ttl_days = days_since_blob_ingestion - - return ttl_days - - class LogQuery: _filter: SessionRecordingsFilter _team_id: int diff --git a/posthog/session_recordings/queries/session_recording_properties.py b/posthog/session_recordings/queries/session_recording_properties.py index 3556af444ed78..49b42f8bfa98f 100644 --- a/posthog/session_recordings/queries/session_recording_properties.py +++ b/posthog/session_recordings/queries/session_recording_properties.py @@ -17,11 +17,6 @@ class EventFiltersSQL(NamedTuple): params: Dict[str, Any] -class SessionRecordingQueryResult(NamedTuple): - results: List - has_more_recording: bool - - class SessionRecordingProperties(EventQuery): _filter: SessionRecordingsFilter _session_ids: List[str] diff --git a/posthog/session_recordings/queries/session_replay_events.py b/posthog/session_recordings/queries/session_replay_events.py index 6521e9f39fdb2..02c2a26519c21 100644 --- a/posthog/session_recordings/queries/session_replay_events.py +++ b/posthog/session_recordings/queries/session_replay_events.py @@ -1,7 +1,12 @@ from datetime import datetime from typing import Optional, Tuple, List +from django.conf import settings + from posthog.clickhouse.client import sync_execute +from posthog.cloud_utils import is_cloud +from posthog.constants import AvailableFeature +from posthog.models.instance_setting import get_instance_setting from posthog.models.team import Team from posthog.session_recordings.models.metadata import ( RecordingMetadata, @@ -9,6 +14,21 @@ class SessionReplayEvents: + def exists(self, session_id: str, team: Team) -> bool: + # TODO we could cache this result when its result is True. + # Once we know that session exists we don't need to check again (until the end of the day since TTL might apply) + result = sync_execute( + """ + SELECT count(1) + FROM session_replay_events + WHERE team_id = %(team_id)s + AND session_id = %(session_id)s + AND min_first_timestamp >= now() - INTERVAL %(recording_ttl_days)s DAY + """, + {"team_id": team.pk, "session_id": session_id, "recording_ttl_days": ttl_days(team)}, + ) + return result[0][0] > 0 + def get_metadata( self, session_id: str, team: Team, recording_start_time: Optional[datetime] = None ) -> Optional[RecordingMetadata]: @@ -66,3 +86,19 @@ def get_metadata( console_warn_count=replay[10], console_error_count=replay[11], ) + + +def ttl_days(team: Team) -> int: + ttl_days = (get_instance_setting("RECORDINGS_TTL_WEEKS") or 3) * 7 + if is_cloud(): + # NOTE: We use Playlists as a proxy to see if they are subbed to Recordings + is_paid = team.organization.is_feature_available(AvailableFeature.RECORDINGS_PLAYLISTS) + ttl_days = settings.REPLAY_RETENTION_DAYS_MAX if is_paid else settings.REPLAY_RETENTION_DAYS_MIN + + # NOTE: The date we started reliably ingested data to blob storage + days_since_blob_ingestion = (datetime.now() - datetime(2023, 8, 1)).days + + if days_since_blob_ingestion < ttl_days: + ttl_days = days_since_blob_ingestion + + return ttl_days diff --git a/posthog/session_recordings/queries/test/test_session_recording.py b/posthog/session_recordings/queries/test/test_session_recording.py deleted file mode 100644 index 28f992fabebd8..0000000000000 --- a/posthog/session_recordings/queries/test/test_session_recording.py +++ /dev/null @@ -1,221 +0,0 @@ -from urllib.parse import urlencode - -from dateutil.relativedelta import relativedelta -from django.http import HttpRequest -from django.utils.timezone import now -from freezegun import freeze_time -from rest_framework.request import Request - -from posthog.models import Filter -from posthog.models.team import Team -from posthog.session_recordings.queries.session_recording_events import SessionRecordingEvents -from posthog.session_recordings.session_recording_helpers import ( - DecompressedRecordingData, -) -from posthog.session_recordings.test.test_factory import create_snapshots, create_snapshot -from posthog.test.base import APIBaseTest, ClickhouseTestMixin - - -def create_recording_filter(session_recording_id, limit=None, offset=None) -> Filter: - params = {} - if limit: - params["limit"] = limit - if offset: - params["offset"] = offset - build_req = HttpRequest() - build_req.META = {"HTTP_HOST": "www.testserver"} - - req = Request( - build_req, f"/api/event/session_recording?session_recording_id={session_recording_id}{urlencode(params)}" # type: ignore - ) - return Filter(request=req, data=params) - - -class TestClickhouseSessionRecording(ClickhouseTestMixin, APIBaseTest): - - maxDiff = None - - def test_get_snapshots(self): - with freeze_time("2020-09-13T12:26:40.000Z"): - create_snapshot( - has_full_snapshot=False, - distinct_id="user", - session_id="1", - timestamp=now(), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - create_snapshot( - has_full_snapshot=False, - distinct_id="user", - session_id="1", - timestamp=now() + relativedelta(seconds=10), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - create_snapshot( - has_full_snapshot=False, - distinct_id="user2", - session_id="2", - timestamp=now() + relativedelta(seconds=20), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - create_snapshot( - has_full_snapshot=False, - distinct_id="user", - session_id="1", - timestamp=now() + relativedelta(seconds=30), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - - filter = create_recording_filter("1") - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id="1" - ).get_snapshots(filter.limit, filter.offset) - - assert recording is not None - self.assertEqual( - recording["snapshot_data_by_window_id"], - { - "": [ - {"timestamp": 1600000000000, "type": 3, "data": {"source": 0}}, - {"timestamp": 1600000010000, "type": 3, "data": {"source": 0}}, - {"timestamp": 1600000030000, "type": 3, "data": {"source": 0}}, - ] - }, - ) - self.assertEqual(recording["has_next"], False) - - def test_get_snapshots_does_not_leak_teams(self): - with freeze_time("2020-09-13T12:26:40.000Z"): - another_team = Team.objects.create(organization=self.organization) - create_snapshot( - has_full_snapshot=False, - distinct_id="user1", - session_id="1", - timestamp=now() + relativedelta(seconds=10), - team_id=another_team.pk, - data={"source": "other team"}, - use_replay_table=False, - use_recording_table=True, - ) - create_snapshot( - has_full_snapshot=False, - distinct_id="user2", - session_id="1", - timestamp=now(), - team_id=self.team.id, - data={"source": 0}, - use_replay_table=False, - use_recording_table=True, - ) - - filter = create_recording_filter("1") - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id="1" - ).get_snapshots(filter.limit, filter.offset) - - assert recording is not None - self.assertEqual( - recording["snapshot_data_by_window_id"], - {"": [{"data": {"source": 0}, "timestamp": 1600000000000, "type": 3}]}, - ) - - def test_get_snapshots_with_no_such_session(self): - filter = create_recording_filter("xxx") - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id="xxx" - ).get_snapshots(filter.limit, filter.offset) - - assert recording is None - - def test_get_chunked_snapshots(self): - with freeze_time("2020-09-13T12:26:40.000Z"): - chunked_session_id = "7" - snapshots_per_chunk = 2 - limit = 20 - for _ in range(30): - create_snapshots( - snapshot_count=snapshots_per_chunk, - distinct_id="user", - session_id=chunked_session_id, - timestamp=now(), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - - filter = create_recording_filter(chunked_session_id) - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id=chunked_session_id - ).get_snapshots(limit, filter.offset) - - assert recording is not None - self.assertEqual(len(recording["snapshot_data_by_window_id"][""]), limit * snapshots_per_chunk) - self.assertTrue(recording["has_next"]) - - def test_get_chunked_snapshots_with_specific_limit_and_offset(self): - with freeze_time("2020-09-13T12:26:40.000Z"): - chunked_session_id = "7" - limit = 10 - offset = 5 - snapshots_per_chunk = 2 - for index in range(16): - create_snapshots( - snapshot_count=snapshots_per_chunk, - distinct_id="user", - session_id=chunked_session_id, - timestamp=now() + relativedelta(minutes=index), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - - filter = create_recording_filter(chunked_session_id, limit, offset) - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id=chunked_session_id - ).get_snapshots(limit, filter.offset) - - assert recording is not None - self.assertEqual(len(recording["snapshot_data_by_window_id"][""]), limit * snapshots_per_chunk) - self.assertEqual(recording["snapshot_data_by_window_id"][""][0]["timestamp"], 1_600_000_300_000) - self.assertTrue(recording["has_next"]) - - def test_get_snapshots_with_date_filter(self): - with freeze_time("2020-09-13T12:26:40.000Z"): - # This snapshot should be filtered out - create_snapshot( - has_full_snapshot=False, - distinct_id="user", - session_id="1", - timestamp=now() - relativedelta(days=2), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - # This snapshot should appear - create_snapshot( - has_full_snapshot=False, - distinct_id="user", - session_id="1", - timestamp=now(), - team_id=self.team.id, - use_replay_table=False, - use_recording_table=True, - ) - - filter = create_recording_filter( - "1", - ) - recording: DecompressedRecordingData | None = SessionRecordingEvents( - team=self.team, session_recording_id="1", recording_start_time=now() - ).get_snapshots(filter.limit, filter.offset) - - assert recording is not None - self.assertEqual(len(recording["snapshot_data_by_window_id"][""]), 1) diff --git a/posthog/session_recordings/queries/test/test_session_recording_list_from_session_replay.py b/posthog/session_recordings/queries/test/test_session_recording_list_from_session_replay.py index 0a4fbbe908ea5..9424a9df2a51c 100644 --- a/posthog/session_recordings/queries/test/test_session_recording_list_from_session_replay.py +++ b/posthog/session_recordings/queries/test/test_session_recording_list_from_session_replay.py @@ -18,8 +18,8 @@ from posthog.models.team import Team from posthog.session_recordings.queries.session_recording_list_from_replay_summary import ( SessionRecordingListFromReplaySummary, - ttl_days, ) +from posthog.session_recordings.queries.session_replay_events import ttl_days from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import ( APIBaseTest, diff --git a/posthog/session_recordings/queries/test/test_session_recording_properties.py b/posthog/session_recordings/queries/test/test_session_recording_properties.py index 387d41bbe1ebc..9844d77006721 100644 --- a/posthog/session_recordings/queries/test/test_session_recording_properties.py +++ b/posthog/session_recordings/queries/test/test_session_recording_properties.py @@ -5,7 +5,7 @@ from posthog.models import Person from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter from posthog.session_recordings.queries.session_recording_properties import SessionRecordingProperties -from posthog.session_recordings.test.test_factory import create_snapshot +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.test.base import BaseTest, ClickhouseTestMixin, _create_event, snapshot_clickhouse_queries @@ -30,8 +30,20 @@ def base_time(self): @snapshot_clickhouse_queries def test_properties_list(self): Person.objects.create(team=self.team, distinct_ids=["user"], properties={"email": "bla"}) - create_snapshot(distinct_id="user", session_id="1", timestamp=self.base_time, team_id=self.team.id) - create_snapshot(distinct_id="user", session_id="2", timestamp=self.base_time, team_id=self.team.id) + produce_replay_summary( + team_id=self.team.id, + session_id="1", + distinct_id="user", + first_timestamp=self.base_time, + last_timestamp=self.base_time, + ) + produce_replay_summary( + team_id=self.team.id, + session_id="2", + distinct_id="user", + first_timestamp=self.base_time, + last_timestamp=self.base_time, + ) event_props = { "$session_id": "1", diff --git a/posthog/session_recordings/session_recording_api.py b/posthog/session_recordings/session_recording_api.py index 6ef5595cad560..bb3516651d668 100644 --- a/posthog/session_recordings/session_recording_api.py +++ b/posthog/session_recordings/session_recording_api.py @@ -11,18 +11,16 @@ from django.http import JsonResponse, HttpResponse from drf_spectacular.utils import extend_schema from loginas.utils import is_impersonated_session -from rest_framework import exceptions, request, serializers, viewsets, status +from rest_framework import exceptions, request, serializers, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from sentry_sdk import capture_exception from posthog.api.person import PersonSerializer from posthog.api.routing import StructuredViewSetMixin from posthog.auth import SharingAccessTokenAuthentication from posthog.constants import SESSION_RECORDINGS_FILTER_IDS -from posthog.models import Filter, User +from posthog.models import User from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter from posthog.models.person.person import PersonDistinctId from posthog.session_recordings.models.session_recording import SessionRecording @@ -39,14 +37,12 @@ ) from posthog.session_recordings.queries.session_recording_properties import SessionRecordingProperties from posthog.rate_limit import ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle +from posthog.session_recordings.queries.session_replay_events import SessionReplayEvents from posthog.session_recordings.realtime_snapshots import get_realtime_snapshots from posthog.session_recordings.snapshots.convert_legacy_snapshots import convert_original_version_lts_recording from posthog.storage import object_storage -from posthog.utils import format_query_params_absolute_url from prometheus_client import Counter -DEFAULT_RECORDING_CHUNK_LIMIT = 20 # Should be tuned to find the best value - SNAPSHOT_SOURCE_REQUESTED = Counter( "session_snapshots_requested_counter", "When calling the API and providing a concrete snapshot type to load.", @@ -54,20 +50,6 @@ ) -def snapshots_response(data: Any) -> Any: - # NOTE: We have seen some issues with encoding of emojis, specifically when there is a lone "surrogate pair". See #13272 for more details - # The Django JsonResponse handles this case, but the DRF Response does not. So we fall back to the Django JsonResponse if we encounter an error - try: - JSONRenderer().render(data=data) - except Exception: - capture_exception( - Exception("DRF Json encoding failed, falling back to Django JsonResponse"), {"response_data": data} - ) - return JsonResponse(data) - - return Response(data) - - class SessionRecordingSerializer(serializers.ModelSerializer): id = serializers.CharField(source="session_id", read_only=True) recording_duration = serializers.IntegerField(source="duration", read_only=True) @@ -253,13 +235,25 @@ def persist(self, request: request.Request, *args: Any, **kwargs: Any) -> Respon return Response({"success": True}) - def _snapshots_v2(self, request: request.Request): + @action(methods=["GET"], detail=True) + def snapshots(self, request: request.Request, **kwargs): """ - This will eventually replace the snapshots endpoint below. - This path only supports loading from S3 or Redis based on query params + Snapshots can be loaded from multiple places: + 1. From S3 if the session is older than our ingestion limit. This will be multiple files that can be streamed to the client + 2. or from Redis if the session is newer than our ingestion limit. + + Clients need to call this API twice. + First without a source parameter to get a list of sources supported by the given session. + And then once for each source in the returned list to get the actual snapshots. + + NB version 1 of this API has been deprecated and ClickHouse stored snapshots are no longer supported. """ recording = self.get_object() + + if not SessionReplayEvents().exists(session_id=str(recording.session_id), team=self.team): + raise exceptions.NotFound("Recording not found") + response_data = {} source = request.GET.get("source") might_have_realtime = True @@ -388,73 +382,6 @@ def _snapshots_v2(self, request: request.Request): return Response(serializer.data) - @action(methods=["GET"], detail=True) - def snapshots(self, request: request.Request, **kwargs): - """ - Snapshots can be loaded from multiple places: - 1. From S3 if the session is older than our ingestion limit. This will be multiple files that can be streamed to the client - 2. From Redis if the session is newer than our ingestion limit. - 3. From Clickhouse whilst we are migrating to the new ingestion method - - NB calling this API without `version=2` in the query params or with no version is deprecated and will be removed in the future - """ - - if request.GET.get("version") == "2": - return self._snapshots_v2(request) - - recording = self.get_object() - - # TODO: Determine if we should try Redis or not based on the recording start time and the S3 responses - - if recording.deleted: - raise exceptions.NotFound("Recording not found") - - if recording.storage_version: - # we're only expected recordings with no snapshot version here - # but a bad assumption about when we could create recordings with a snapshot version - # of 2023-08-01 means we need to "force upgrade" these requests to version 2 of the API - # so, we issue a temporary redirect to the same URL request but with version 2 in the query params - params = request.GET.copy() - params["version"] = "2" - return Response(status=status.HTTP_302_FOUND, headers={"Location": f"{request.path}?{params.urlencode()}"}) - - # TODO: Why do we use a Filter? Just swap to norma, offset, limit pagination - filter = Filter(request=request) - limit = filter.limit if filter.limit else DEFAULT_RECORDING_CHUNK_LIMIT - offset = filter.offset if filter.offset else 0 - - event_properties = {"team_id": self.team.pk, "session_being_loaded": recording.session_id, "offset": offset} - - if request.headers.get("X-POSTHOG-SESSION-ID"): - event_properties["$session_id"] = request.headers["X-POSTHOG-SESSION-ID"] - - posthoganalytics.capture( - self._distinct_id_from_request(request), "v1 session recording snapshots viewed", event_properties - ) - - try: - recording.load_snapshots(limit, offset) - except NotImplementedError as e: - capture_exception(e) - raise exceptions.NotFound("Storage version 2023-08-01 can only be accessed via V2 of this endpoint") - - if offset == 0: - if not recording.snapshot_data_by_window_id: - raise exceptions.NotFound("Snapshots not found") - - if recording.can_load_more_snapshots: - next_url = format_query_params_absolute_url(request, offset + limit, limit) if True else None - else: - next_url = None - - res = { - "storage": recording.storage, - "next": next_url, - "snapshot_data_by_window_id": recording.snapshot_data_by_window_id, - } - - return snapshots_response(res) - @staticmethod def _distinct_id_from_request(request): if isinstance(request.user, AnonymousUser): diff --git a/posthog/session_recordings/session_recording_helpers.py b/posthog/session_recordings/session_recording_helpers.py index 960ac0021c817..0cdff5642f4fe 100644 --- a/posthog/session_recordings/session_recording_helpers.py +++ b/posthog/session_recordings/session_recording_helpers.py @@ -3,17 +3,13 @@ import json from collections import defaultdict from datetime import datetime, timezone -from typing import Any, Callable, DefaultDict, Dict, Generator, List, Optional, Tuple +from typing import Any, Callable, Dict, Generator, List, Tuple -from dateutil.parser import ParserError, parse +from dateutil.parser import parse from sentry_sdk.api import capture_exception -from posthog.models import utils from posthog.session_recordings.models.metadata import ( - DecompressedRecordingData, SessionRecordingEventSummary, - SnapshotData, - SnapshotDataTaggedWithWindowId, ) from posthog.utils import flatten @@ -88,44 +84,6 @@ class RRWEB_MAP_EVENT_DATA_TYPE: Event = Dict[str, Any] -def legacy_preprocess_session_recording_events_for_clickhouse( - events: List[Event], chunk_size=512 * 1024 -) -> List[Event]: - return _process_windowed_events(events, lambda x: legacy_compress_and_chunk_snapshots(x, chunk_size=chunk_size)) - - -def legacy_compress_and_chunk_snapshots(events: List[Event], chunk_size=512 * 1024) -> Generator[Event, None, None]: - data_list = list(flatten([event["properties"]["$snapshot_data"] for event in events], max_depth=1)) - session_id = events[0]["properties"]["$session_id"] - window_id = events[0]["properties"].get("$window_id") - has_full_snapshot = any(snapshot_data["type"] == RRWEB_MAP_EVENT_TYPE.FullSnapshot for snapshot_data in data_list) - compressed_data = compress_to_string(json.dumps(data_list)) - - id = str(utils.UUIDT()) - chunks = chunk_string(compressed_data, chunk_size) - - for index, chunk in enumerate(chunks): - yield { - **events[0], - "properties": { - **events[0]["properties"], - "$session_id": session_id, - "$window_id": window_id, - # If it is the first chunk we include all events - "$snapshot_data": { - "chunk_id": id, - "chunk_index": index, - "chunk_count": len(chunks), - "data": chunk, - "compression": "gzip-base64", - "has_full_snapshot": has_full_snapshot, - # We only store this field on the first chunk as it contains all events, not just this chunk - "events_summary": get_events_summary_from_snapshot_data(data_list) if index == 0 else None, - }, - }, - } - - def split_replay_events(events: List[Event]) -> Tuple[List[Event], List[Event]]: replay, other = [], [] @@ -135,6 +93,7 @@ def split_replay_events(events: List[Event]) -> Tuple[List[Event], List[Event]]: return replay, other +# TODO is this covered by enough tests post-blob ingester rollout def preprocess_replay_events_for_blob_ingestion(events: List[Event], max_size_bytes=1024 * 1024) -> List[Event]: return _process_windowed_events(events, lambda x: preprocess_replay_events(x, max_size_bytes=max_size_bytes)) @@ -255,11 +214,6 @@ def _process_windowed_events( return result -def chunk_string(string: str, chunk_length: int) -> List[str]: - """Split a string into chunk_length-sized elements. Reversal operation: `''.join()`.""" - return [string[0 + offset : chunk_length + offset] for offset in range(0, len(string), chunk_length)] - - def is_unprocessed_snapshot_event(event: Dict) -> bool: try: is_snapshot = event["event"] == "$snapshot" @@ -274,113 +228,13 @@ def is_unprocessed_snapshot_event(event: Dict) -> bool: raise ValueError('$snapshot events must contain property "$snapshot_data"!') -def compress_to_string(json_string: str) -> str: - compressed_data = gzip.compress(json_string.encode("utf-16", "surrogatepass")) - return base64.b64encode(compressed_data).decode("utf-8") - - +# this is kept around as we upgrade older recordings in long term storage on demand. +# TODO: remove this once all recordings are upgraded def decompress(base64data: str) -> str: compressed_bytes = base64.b64decode(base64data) return gzip.decompress(compressed_bytes).decode("utf-16", "surrogatepass") -def decompress_chunked_snapshot_data( - all_recording_events: List[SnapshotDataTaggedWithWindowId], - limit: Optional[int] = None, - offset: int = 0, - return_only_activity_data: bool = False, -) -> DecompressedRecordingData: - """ - Before data is stored in clickhouse, it is compressed and then chunked. This function - gets back to the original data by unchunking the events and then decompressing the data. - - If limit + offset is provided, then it will paginate the decompression by chunks (not by events, because - you can't decompress an incomplete chunk). - - Depending on the size of the recording, this function can return a lot of data. To decrease the - memory used, you should either use the pagination parameters or pass in 'return_only_activity_data' which - drastically reduces the size of the data returned if you only want the activity data (used for metadata calculation) - """ - - if len(all_recording_events) == 0: - return DecompressedRecordingData(has_next=False, snapshot_data_by_window_id={}) - - snapshot_data_by_window_id = defaultdict(list) - chunks_collector: DefaultDict[str, List[SnapshotDataTaggedWithWindowId]] = defaultdict(list) - processed_chunk_ids = set() - count = 0 - - for event in all_recording_events: - # Handle unchunked snapshots - if "chunk_id" not in event["snapshot_data"]: - count += 1 - - if offset >= count: - continue - - if event["snapshot_data"].get("data_items"): - decompressed_items = [json.loads(decompress(x)) for x in event["snapshot_data"]["data_items"]] - - # New format where the event is a list of raw rrweb events - snapshot_data_by_window_id[event["window_id"]].extend( - event["snapshot_data"]["events_summary"] if return_only_activity_data else decompressed_items - ) - else: - # Old format where the event is just a single raw rrweb event - snapshot_data_by_window_id[event["window_id"]].append( - get_events_summary_from_snapshot_data([event["snapshot_data"]])[0] - if return_only_activity_data - else event["snapshot_data"] - ) - else: - # Handle chunked snapshots - if event["snapshot_data"]["chunk_id"] in processed_chunk_ids: - continue - - chunks = chunks_collector[event["snapshot_data"]["chunk_id"]] - chunks.append(event) - - deduplicated_chunks = {} - for chunk in chunks: - # reduce the chunks into deduplicated chunks by chunk_id taking only the first seen for each chunk_id - if chunk["snapshot_data"]["chunk_index"] not in deduplicated_chunks: - deduplicated_chunks[chunk["snapshot_data"]["chunk_index"]] = chunk - - chunks = chunks_collector[event["snapshot_data"]["chunk_id"]] = list(deduplicated_chunks.values()) - - if len(chunks) == event["snapshot_data"]["chunk_count"]: - count += 1 - chunks_collector[event["snapshot_data"]["chunk_id"]] = [] - - # Somehow mark this chunk_id as processed... - processed_chunk_ids.add(event["snapshot_data"]["chunk_id"]) - - if offset >= count: - continue - - b64_compressed_data = "".join( - chunk["snapshot_data"]["data"] - for chunk in sorted(chunks, key=lambda c: c["snapshot_data"]["chunk_index"]) - ) - decompressed_data = json.loads(decompress(b64_compressed_data)) - - if type(decompressed_data) is dict: - decompressed_data = [decompressed_data] - - if return_only_activity_data: - events_with_only_activity_data = get_events_summary_from_snapshot_data(decompressed_data) - snapshot_data_by_window_id[event["window_id"]].extend(events_with_only_activity_data) - else: - snapshot_data_by_window_id[event["window_id"]].extend(decompressed_data) - - if limit and count >= offset + limit: - break - - has_next = count < len(all_recording_events) - - return DecompressedRecordingData(has_next=has_next, snapshot_data_by_window_id=snapshot_data_by_window_id) - - def is_active_event(event: SessionRecordingEventSummary) -> bool: """ Determines which rr-web events are "active" - meaning user generated @@ -406,55 +260,5 @@ def convert_to_timestamp(source: str) -> int: return int(parse(source).timestamp() * 1000) -def get_events_summary_from_snapshot_data( - snapshot_data: List[SnapshotData | None], -) -> List[SessionRecordingEventSummary]: - """ - Extract a minimal representation of the snapshot data events for easier querying. - 'data' and 'data.payload' values are included as long as they are strings or numbers - and in the inclusion list to keep the payload minimal - """ - events_summary = [] - - for event in snapshot_data: - if not event or "timestamp" not in event or "type" not in event: - continue - - # Get all top level data values - data = { - key: value - for key, value in event.get("data", {}).items() - if type(value) in [str, int] and key in EVENT_SUMMARY_DATA_INCLUSIONS - } - # Some events have a payload, some values of which we want - if event.get("data", {}).get("payload"): - # Make sure the payload is a dict before we access it - if isinstance(event["data"]["payload"], dict): - data["payload"] = { - key: value - for key, value in event["data"]["payload"].items() - if type(value) in [str, int] and f"payload.{key}" in EVENT_SUMMARY_DATA_INCLUSIONS - } - - # noinspection PyBroadException - try: - events_summary.append( - SessionRecordingEventSummary( - timestamp=int(event["timestamp"]) - if isinstance(event["timestamp"], (int, float)) - else convert_to_timestamp(event["timestamp"]), - type=event["type"], - data=data, - ) - ) - except ParserError: - capture_exception() - - # No guarantees are made about order so, we sort here to be sure - events_summary.sort(key=lambda x: x["timestamp"]) - - return events_summary - - def byte_size_dict(x: Dict | List) -> int: return len(json.dumps(x)) diff --git a/posthog/session_recordings/test/test_factory.py b/posthog/session_recordings/test/test_factory.py deleted file mode 100644 index 4213ff02f5566..0000000000000 --- a/posthog/session_recordings/test/test_factory.py +++ /dev/null @@ -1,217 +0,0 @@ -import json -from datetime import datetime, timedelta -from typing import Dict, List, Optional -from uuid import uuid4 - -import structlog - -from posthog.client import sync_execute -from posthog.kafka_client.client import ClickhouseProducer -from posthog.kafka_client.topics import KAFKA_CLICKHOUSE_SESSION_RECORDING_EVENTS -from posthog.session_recordings.sql.session_recording_event_sql import INSERT_SESSION_RECORDING_EVENT_SQL -from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary -from posthog.session_recordings.session_recording_helpers import ( - RRWEB_MAP_EVENT_TYPE, - legacy_preprocess_session_recording_events_for_clickhouse, -) -from posthog.utils import cast_timestamp_or_now - -logger = structlog.get_logger(__name__) - -MAX_KAFKA_MESSAGE_LENGTH = 800_000 -MAX_INSERT_LENGTH = 15_000_000 - - -def _insert_session_recording_event( - team_id: int, - distinct_id: str, - session_id: str, - window_id: str, - timestamp: datetime, - snapshot_data: dict, -) -> str: - uuid = uuid4() - snapshot_data_json = json.dumps(snapshot_data) - timestamp_str = cast_timestamp_or_now(timestamp) - data = { - "uuid": str(uuid), - "team_id": team_id, - "distinct_id": distinct_id, - "session_id": session_id, - "window_id": window_id, - "snapshot_data": snapshot_data_json, - "timestamp": timestamp_str, - "created_at": timestamp_str, - } - if len(snapshot_data_json) <= MAX_KAFKA_MESSAGE_LENGTH: - p = ClickhouseProducer() - p.produce(sql=INSERT_SESSION_RECORDING_EVENT_SQL(), topic=KAFKA_CLICKHOUSE_SESSION_RECORDING_EVENTS, data=data) - elif len(snapshot_data_json) <= MAX_INSERT_LENGTH: - sync_execute(INSERT_SESSION_RECORDING_EVENT_SQL(), data, settings={"max_query_size": MAX_INSERT_LENGTH}) - - return str(uuid) - - -def create_session_recording_events( - team_id: int, - timestamp: datetime, - distinct_id: str, - session_id: str, - window_id: Optional[str] = None, - # If not given we will create a mock full snapshot - snapshots: Optional[List[dict]] = None, - chunk_size: Optional[int] = 512 * 1024, - use_replay_table: bool = True, - use_recording_table: bool = False, -) -> None: - if use_replay_table: - produce_replay_summary( - team_id=team_id, - session_id=session_id, - distinct_id=distinct_id, - first_timestamp=timestamp, - last_timestamp=timestamp, - ) - - if use_recording_table: - if window_id is None: - window_id = session_id - - if not snapshots: - snapshots = [ - { - "type": RRWEB_MAP_EVENT_TYPE.FullSnapshot, - "data": {}, - "timestamp": round(timestamp.timestamp() * 1000), # NOTE: rrweb timestamps are milliseconds - } - ] - - # We use the same code path for chunking events by mocking this as an typical posthog event - mock_events = [ - { - "event": "$snapshot", - "properties": { - "$session_id": session_id, - "$window_id": window_id, - "$snapshot_data": snapshot, - }, - } - for snapshot in snapshots - ] - - for event in legacy_preprocess_session_recording_events_for_clickhouse(mock_events, chunk_size=chunk_size): - _insert_session_recording_event( - team_id=team_id, - distinct_id=distinct_id, - session_id=session_id, - window_id=window_id, - timestamp=timestamp, - snapshot_data=event["properties"]["$snapshot_data"], - ) - - -# Pre-compression and events_summary additions which potentially existed for some self-hosted instances -def create_uncompressed_session_recording_event( - team_id: int, - distinct_id: str, - session_id: str, - window_id: str, - timestamp: datetime, - snapshot_data: dict, -) -> str: - return _insert_session_recording_event( - team_id=team_id, - distinct_id=distinct_id, - session_id=session_id, - window_id=window_id, - timestamp=timestamp, - snapshot_data=snapshot_data, - ) - - -def create_snapshot( - session_id: str, - timestamp: datetime, - team_id: int, - distinct_id: Optional[str] = None, - window_id: str = "", - has_full_snapshot: bool = True, - type: Optional[int] = None, - data: Optional[Dict] = None, - use_replay_table=True, - use_recording_table=False, -) -> None: - if not data: - data = {"source": 0} - - snapshot_data = { - "data": {**data}, - "timestamp": round(timestamp.timestamp() * 1000), # NOTE: rrweb timestamps are milliseconds - "type": type - or (RRWEB_MAP_EVENT_TYPE.FullSnapshot if has_full_snapshot else RRWEB_MAP_EVENT_TYPE.IncrementalSnapshot), - } - - create_session_recording_events( - team_id=team_id, - timestamp=timestamp, - distinct_id=distinct_id if distinct_id else str(uuid4()), - session_id=session_id, - window_id=window_id, - snapshots=[snapshot_data], - use_recording_table=use_recording_table, - use_replay_table=use_replay_table, - ) - - -def create_snapshots( - snapshot_count: int, - distinct_id: str, - session_id: str, - timestamp: datetime, - team_id: int, - window_id: str = "", - has_full_snapshot: bool = True, - source: int = 0, - chunk_size: Optional[int] = 512 * 1024, - use_replay_table=True, - use_recording_table=False, -): - snapshots = [] - for index in range(snapshot_count): - snapshots.append( - { - "type": 2 if has_full_snapshot else 3, - "data": { - "source": source, - "texts": [], - "attributes": [], - "removes": [], - "adds": [ - { - "parentId": 4, - "nextId": 386, - "node": { - "type": 2, - "tagName": "style", - "attributes": {"data-emotion": "css"}, - "childNodes": [], - "id": 729, - }, - } - ], - }, - "timestamp": (timestamp + timedelta(seconds=index)).timestamp() * 1000, - }, - ) - - return create_session_recording_events( - team_id=team_id, - timestamp=timestamp, - distinct_id=distinct_id, - session_id=session_id, - window_id=window_id, - snapshots=snapshots, - chunk_size=chunk_size, - use_recording_table=use_recording_table, - use_replay_table=use_replay_table, - ) diff --git a/posthog/session_recordings/test/test_lts_session_recordings.py b/posthog/session_recordings/test/test_lts_session_recordings.py index b98286e045f25..b16d873b93d7b 100644 --- a/posthog/session_recordings/test/test_lts_session_recordings.py +++ b/posthog/session_recordings/test/test_lts_session_recordings.py @@ -3,6 +3,7 @@ from unittest.mock import patch, MagicMock, call, Mock from posthog.models import Team +from posthog.models.signals import mute_selected_signals from posthog.session_recordings.models.session_recording import SessionRecording from posthog.test.base import APIBaseTest, ClickhouseTestMixin, QueryMatchingTest @@ -18,8 +19,11 @@ def setUp(self): # Create a new team each time to ensure no clashing between tests self.team = Team.objects.create(organization=self.organization, name="New Team") + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_2023_08_01_version_stored_snapshots_can_be_gathered(self, mock_list_objects: MagicMock) -> None: + def test_2023_08_01_version_stored_snapshots_can_be_gathered( + self, mock_list_objects: MagicMock, _mock_exists: MagicMock + ) -> None: session_id = str(uuid.uuid4()) lts_storage_path = "purposefully/not/what/we/would/calculate/to/prove/this/is/used" @@ -68,8 +72,11 @@ def list_objects_func(path: str) -> List[str]: ], } + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_original_version_stored_snapshots_can_be_gathered(self, mock_list_objects: MagicMock) -> None: + def test_original_version_stored_snapshots_can_be_gathered( + self, mock_list_objects: MagicMock, _mock_exists: MagicMock + ) -> None: session_id = str(uuid.uuid4()) lts_storage_path = "purposefully/not/what/we/would/calculate/to/prove/this/is/used" @@ -78,19 +85,15 @@ def list_objects_func(path: str) -> List[str]: mock_list_objects.side_effect = list_objects_func - recording = SessionRecording.objects.create( - team=self.team, - session_id=session_id, - # to avoid auto-persistence kicking in when this is None - storage_version="not a know version", - object_storage_path=lts_storage_path, - start_time="1970-01-01T00:00:00.001000Z", - end_time="1970-01-01T00:00:00.002000Z", - ) - # why is this necessary? I don't know... - # but without it, the object has the default storage path 🤷️ - recording.object_storage_path = lts_storage_path - recording.save() + with mute_selected_signals(): + SessionRecording.objects.create( + team=self.team, + session_id=session_id, + storage_version=None, + object_storage_path=lts_storage_path, + start_time="1970-01-01T00:00:00.001000Z", + end_time="1970-01-01T00:00:00.002000Z", + ) response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?version=2") response_data = response.json() @@ -109,11 +112,16 @@ def list_objects_func(path: str) -> List[str]: ], } + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.requests.get") @patch("posthog.session_recordings.session_recording_api.object_storage.get_presigned_url") @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") def test_2023_08_01_version_stored_snapshots_can_be_loaded( - self, mock_list_objects: MagicMock, mock_get_presigned_url: MagicMock, mock_requests: MagicMock + self, + mock_list_objects: MagicMock, + mock_get_presigned_url: MagicMock, + mock_requests: MagicMock, + _mock_exists: MagicMock, ) -> None: session_id = str(uuid.uuid4()) lts_storage_path = "purposefully/not/what/we/would/calculate/to/prove/this/is/used" @@ -165,6 +173,7 @@ def list_objects_func(path: str) -> List[str]: assert response_data == "the file contents" + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.requests.get") @patch("posthog.session_recordings.session_recording_api.object_storage.tag") @patch("posthog.session_recordings.session_recording_api.object_storage.write") @@ -179,6 +188,7 @@ def test_original_version_stored_snapshots_can_be_loaded_without_upversion( mock_write: MagicMock, mock_tag: MagicMock, mock_requests: MagicMock, + _mock_exists: MagicMock, ) -> None: session_id = str(uuid.uuid4()) lts_storage_path = "purposefully/not/what/we/would/calculate/to/prove/this/is/used" @@ -198,18 +208,16 @@ def list_objects_func(path: str) -> List[str]: mock_requests.return_value.__enter__.return_value = mock_response mock_requests.return_value.__exit__.return_value = None - recording = SessionRecording.objects.create( - team=self.team, - session_id=session_id, - # to avoid auto-persistence kicking in when this is None - storage_version="not a know version", - object_storage_path=lts_storage_path, - start_time="1970-01-01T00:00:00.001000Z", - end_time="1970-01-01T00:00:00.002000Z", - ) - # something in the setup is triggering a path that saves the recording without the provided path so - recording.object_storage_path = lts_storage_path - recording.save() + with mute_selected_signals(): + SessionRecording.objects.create( + team=self.team, + session_id=session_id, + # to avoid auto-persistence kicking in when this is None + storage_version="not a know version", + object_storage_path=lts_storage_path, + start_time="1970-01-01T00:00:00.001000Z", + end_time="1970-01-01T00:00:00.002000Z", + ) query_parameters = [ "source=blob", diff --git a/posthog/session_recordings/test/test_session_recording_helpers.py b/posthog/session_recordings/test/test_session_recording_helpers.py new file mode 100644 index 0000000000000..6c64d84efaf78 --- /dev/null +++ b/posthog/session_recordings/test/test_session_recording_helpers.py @@ -0,0 +1,379 @@ +import json +import math +import random +import string +from datetime import datetime +from typing import Any, List, Tuple + +import pytest +from pytest_mock import MockerFixture + +from posthog.session_recordings.session_recording_helpers import ( + RRWEB_MAP_EVENT_TYPE, + SessionRecordingEventSummary, + is_active_event, + preprocess_replay_events_for_blob_ingestion, + split_replay_events, +) + +MILLISECOND_TIMESTAMP = round(datetime(2019, 1, 1).timestamp() * 1000) + + +def create_activity_data(timestamp: datetime, is_active: bool): + return SessionRecordingEventSummary( + timestamp=round(timestamp.timestamp() * 1000), + type=3, + data=dict(source=1 if is_active else -1), + ) + + +def mock_capture_flow(events: List[dict], max_size_bytes=512 * 1024) -> Tuple[List[dict], List[dict]]: + """ + Returns the legacy events and the new flow ones + """ + replay_events, other_events = split_replay_events(events) + + new_replay_events = preprocess_replay_events_for_blob_ingestion(replay_events, max_size_bytes=max_size_bytes) + + # TODO this should only be returning the second part of the tuple, it used to return legacy snapshot data too + return other_events, new_replay_events + other_events + + +def test_preprocess_with_no_recordings(): + events = [{"event": "$pageview"}, {"event": "$pageleave"}] + assert mock_capture_flow(events)[0] == events + + +@pytest.fixture +def raw_snapshot_events(): + return [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 2, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + ] + + +@pytest.fixture +def chunked_and_compressed_snapshot_events(): + chunk_1_events = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$snapshot_data": {"type": 4, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$snapshot_data": {"type": 2, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + ] + chunk_2_events = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": { + "type": 3, + "timestamp": MILLISECOND_TIMESTAMP, + "data": {"source": 2}, + }, + "distinct_id": "abc123", + }, + }, + ] + return list(mock_capture_flow(chunk_1_events)[0]) + list(mock_capture_flow(chunk_2_events)[0]) + + +def test_is_active_event(): + timestamp = round(datetime.now().timestamp() * 1000) + assert is_active_event({"timestamp": timestamp, "type": 3, "data": {}}) is False + assert is_active_event({"timestamp": timestamp, "type": 2, "data": {"source": 3}}) is False + assert is_active_event({"timestamp": timestamp, "type": 3, "data": {"source": 3}}) is True + + +def test_new_ingestion(raw_snapshot_events, mocker: MockerFixture): + mocker.patch("time.time", return_value=0) + + big_payload = "".join(random.choices(string.ascii_uppercase + string.digits, k=1025)) + + events = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": { + "type": RRWEB_MAP_EVENT_TYPE.FullSnapshot, + "timestamp": 123, + "something": big_payload, + }, + "distinct_id": "abc123", + }, + }, + ] + + assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ + { + "event": "$snapshot_items", + "properties": { + "distinct_id": "abc123", + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [ + {"type": 3, "timestamp": 1546300800000}, + {"type": 3, "timestamp": 1546300800000}, + { + "type": 2, + "timestamp": 123, + "something": big_payload, + }, + ], + }, + } + ] + + +def test_new_ingestion_large_full_snapshot_is_separated(raw_snapshot_events, mocker: MockerFixture): + mocker.patch("time.time", return_value=0) + + big_payload = "".join(random.choices(string.ascii_uppercase + string.digits, k=10000)) + + events = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 3, "timestamp": MILLISECOND_TIMESTAMP}, + "distinct_id": "abc123", + }, + }, + ] + [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": { + "type": RRWEB_MAP_EVENT_TYPE.FullSnapshot, + "timestamp": 123, + "something": big_payload, + }, + "distinct_id": "abc123", + }, + }, + ] + + assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ + { + "event": "$snapshot_items", + "properties": { + "distinct_id": "abc123", + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [ + { + "type": 2, + "timestamp": 123, + "something": big_payload, + } + ], + }, + }, + { + "event": "$snapshot_items", + "properties": { + "distinct_id": "abc123", + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [{"type": 3, "timestamp": 1546300800000}, {"type": 3, "timestamp": 1546300800000}], + }, + }, + ] + + +def test_new_ingestion_large_non_full_snapshots_are_separated(raw_snapshot_events, mocker: MockerFixture): + mocker.patch("posthog.models.utils.UUIDT", return_value="0178495e-8521-0000-8e1c-2652fa57099b") + mocker.patch("time.time", return_value=0) + + almost_too_big_payloads = [ + "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), + "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), + ] + + events = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 7, "timestamp": 234, "something": almost_too_big_payloads[0]}, + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_data": {"type": 8, "timestamp": 123, "something": almost_too_big_payloads[1]}, + "distinct_id": "abc123", + }, + }, + ] + assert list(mock_capture_flow(events, max_size_bytes=2000)[1]) == [ + { + "event": "$snapshot_items", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [{"type": 7, "timestamp": 234, "something": almost_too_big_payloads[0]}], + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot_items", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [{"type": 8, "timestamp": 123, "something": almost_too_big_payloads[1]}], + "distinct_id": "abc123", + }, + }, + ] + + +def test_new_ingestion_groups_using_snapshot_bytes_if_possible(raw_snapshot_events, mocker: MockerFixture): + mocker.patch("posthog.models.utils.UUIDT", return_value="0178495e-8521-0000-8e1c-2652fa57099b") + mocker.patch("time.time", return_value=0) + + almost_too_big_event = { + "type": 7, + "timestamp": 234, + "something": "".join(random.choices(string.ascii_uppercase + string.digits, k=1024)), + } + + small_event = { + "type": 7, + "timestamp": 234, + "something": "small", + } + + events: List[Any] = [ + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_bytes": len(json.dumps([small_event, small_event])), + "$snapshot_data": [small_event, small_event], + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_bytes": len(json.dumps([almost_too_big_event])), + "$snapshot_data": [almost_too_big_event], + "distinct_id": "abc123", + }, + }, + { + "event": "$snapshot", + "properties": { + "$session_id": "1234", + "$window_id": "1", + "$snapshot_bytes": len(json.dumps([small_event, small_event, small_event])), + "$snapshot_data": [small_event, small_event, small_event], + "distinct_id": "abc123", + }, + }, + ] + + assert [event["properties"]["$snapshot_bytes"] for event in events] == [106, 1072, 159] + + space_with_headroom = math.ceil((106 + 1072 + 50) * 1.05) + assert list(mock_capture_flow(events, max_size_bytes=space_with_headroom)[1]) == [ + { + "event": "$snapshot_items", + "properties": { + "distinct_id": "abc123", + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [ + small_event, + small_event, + almost_too_big_event, + ], + }, + }, + { + "event": "$snapshot_items", + "properties": { + "distinct_id": "abc123", + "$session_id": "1234", + "$window_id": "1", + "$snapshot_items": [small_event, small_event, small_event], + }, + }, + ] diff --git a/posthog/session_recordings/test/test_session_recordings.py b/posthog/session_recordings/test/test_session_recordings.py index 03b495047c9f9..61c05d993ee4a 100644 --- a/posthog/session_recordings/test/test_session_recordings.py +++ b/posthog/session_recordings/test/test_session_recordings.py @@ -19,7 +19,6 @@ from posthog.models.filters.session_recordings_filter import SessionRecordingsFilter from posthog.models.team import Team from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary -from posthog.session_recordings.test.test_factory import create_session_recording_events from posthog.test.base import ( APIBaseTest, ClickhouseTestMixin, @@ -36,6 +35,7 @@ def setUp(self): super().setUp() # Create a new team each time to ensure no clashing between tests + # TODO this is pretty slow, we should change assertions so that we don't need it self.team = Team.objects.create(organization=self.organization, name="New Team") def create_snapshot( @@ -65,15 +65,12 @@ def create_snapshot( if snapshot_data: snapshot.update(snapshot_data) - create_session_recording_events( + produce_replay_summary( team_id=team_id, - distinct_id=distinct_id, - timestamp=timestamp, session_id=session_id, - window_id=window_id, - snapshots=[snapshot], - use_replay_table=use_replay_table, - use_recording_table=use_recording_table, + distinct_id=distinct_id, + first_timestamp=timestamp, + last_timestamp=timestamp, ) def create_snapshots( @@ -115,15 +112,12 @@ def create_snapshots( } ) - create_session_recording_events( + produce_replay_summary( team_id=self.team.pk, - distinct_id=distinct_id, - timestamp=timestamp, session_id=session_id, - window_id=window_id, - snapshots=snapshots, - use_replay_table=use_replay_table, - use_recording_table=use_recording_table, + distinct_id=distinct_id, + first_timestamp=timestamp, + last_timestamp=timestamp, ) def test_get_session_recordings(self): @@ -261,14 +255,20 @@ def test_viewed_state_of_session_recording_version_3(self): team=self.team, distinct_ids=["u1"], properties={"$some_prop": "something", "email": "bob@bob.com"} ) base_time = (now() - timedelta(days=1)).replace(microsecond=0) - SessionRecordingViewed.objects.create(team=self.team, user=self.user, session_id="1") - self.create_snapshot("u1", "1", base_time) - self.create_snapshot("u1", "2", base_time + relativedelta(seconds=30)) + session_id_one = "1" + session_id_two = "2" + + SessionRecordingViewed.objects.create(team=self.team, user=self.user, session_id=session_id_one) + self.create_snapshot("u1", session_id_one, base_time) + self.create_snapshot("u1", session_id_two, base_time + relativedelta(seconds=30)) response = self.client.get(f"/api/projects/{self.team.id}/session_recordings") response_data = response.json() - assert [(r["id"], r["viewed"]) for r in response_data["results"]] == [("2", False), ("1", True)] + assert [(r["id"], r["viewed"]) for r in response_data["results"]] == [ + (session_id_two, False), + (session_id_one, True), + ] def test_setting_viewed_state_of_session_recording(self): Person.objects.create( @@ -368,82 +368,6 @@ def test_get_single_session_recording_metadata(self): "storage": "object_storage", } - def test_get_default_limit_of_chunks(self): - # TODO import causes circular reference... but we're going to delete this soon so... - from posthog.session_recordings.session_recording_api import DEFAULT_RECORDING_CHUNK_LIMIT - - base_time = now() - num_snapshots = DEFAULT_RECORDING_CHUNK_LIMIT + 10 - - for _ in range(num_snapshots): - self.create_snapshot("user", "1", base_time, use_recording_table=True, use_replay_table=False) - - response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1/snapshots") - response_data = response.json() - self.assertEqual(len(response_data["snapshot_data_by_window_id"][""]), DEFAULT_RECORDING_CHUNK_LIMIT) - - def test_get_snapshots_is_compressed(self): - base_time = now() - num_snapshots = 2 # small contents aren't compressed, needs to be enough data to trigger compression - - for _ in range(num_snapshots): - self.create_snapshot("user", "1", base_time, use_recording_table=True) - - custom_headers = {"HTTP_ACCEPT_ENCODING": "gzip"} - response = self.client.get( - f"/api/projects/{self.team.id}/session_recordings/1/snapshots", - data=None, - follow=False, - secure=False, - **custom_headers, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.headers.get("Content-Encoding", None), "gzip") - - def test_get_snapshots_for_chunked_session_recording(self): - # TODO import causes circular reference... but we're going to delete this soon so... - from posthog.session_recordings.session_recording_api import DEFAULT_RECORDING_CHUNK_LIMIT - - chunked_session_id = "chunk_id" - expected_num_requests = 3 - num_chunks = 60 - snapshots_per_chunk = 2 - - with freeze_time("2020-09-13T12:26:40.000Z"): - start_time = now() - for index, s in enumerate(range(num_chunks)): - self.create_snapshots( - snapshots_per_chunk, - "user", - chunked_session_id, - start_time + relativedelta(minutes=s), - window_id="1" if index % 2 == 0 else "2", - use_recording_table=True, - use_replay_table=False, - ) - - next_url = f"/api/projects/{self.team.id}/session_recordings/{chunked_session_id}/snapshots" - - for i in range(expected_num_requests): - response = self.client.get(next_url) - response_data = response.json() - - self.assertEqual( - len(response_data["snapshot_data_by_window_id"]["1"]), - snapshots_per_chunk * DEFAULT_RECORDING_CHUNK_LIMIT / 2, - ) - self.assertEqual( - len(response_data["snapshot_data_by_window_id"]["2"]), - snapshots_per_chunk * DEFAULT_RECORDING_CHUNK_LIMIT / 2, - ) - if i == expected_num_requests - 1: - self.assertIsNone(response_data["next"]) - else: - self.assertIsNotNone(response_data["next"]) - - next_url = response_data["next"] - def test_single_session_recording_doesnt_leak_teams(self): another_team = Team.objects.create(organization=self.organization) self.create_snapshot("user", "id_no_team_leaking", now() - relativedelta(days=1), team_id=another_team.pk) @@ -451,7 +375,7 @@ def test_single_session_recording_doesnt_leak_teams(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/id_no_team_leaking/snapshots") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND, response.json()) def test_session_recording_with_no_person(self): produce_replay_summary( @@ -523,37 +447,6 @@ def test_empty_list_session_ids_filter_returns_no_recordings(self): self.assertEqual(len(response_data["results"]), 0) - def test_regression_encoded_emojis_dont_crash(self): - Person.objects.create( - team=self.team, distinct_ids=["user"], properties={"$some_prop": "something", "email": "bob@bob.com"} - ) - with freeze_time("2022-01-01T12:00:00.000Z"): - self.create_snapshot( - "user", - "1", - now() - relativedelta(days=1), - # TODO do we need a version of this that writes to blob storage? - snapshot_data={"texts": ["\\ud83d\udc83\\ud83c\\udffb"]}, # This is an invalid encoded emoji - use_recording_table=True, - ) - - response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1/snapshots") - self.assertEqual(response.status_code, status.HTTP_200_OK) - response_data = response.json() - - assert not response_data["next"] - assert response_data["snapshot_data_by_window_id"] == { - "": [ - { - "texts": ["\\ud83d\udc83\\ud83c\\udffb"], - "timestamp": 1640952000000.0, - "has_full_snapshot": True, - "type": 2, - "data": {"source": 0}, - } - ] - } - def test_delete_session_recording(self): self.create_snapshot("user", "1", now() - relativedelta(days=1), team_id=self.team.pk) response = self.client.delete(f"/api/projects/{self.team.id}/session_recordings/1") @@ -562,20 +455,27 @@ def test_delete_session_recording(self): response = self.client.delete(f"/api/projects/{self.team.id}/session_recordings/1") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_persist_session_recording(self): + @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects", return_value=2) + def test_persist_session_recording(self, _mock_copy_objects: MagicMock) -> None: self.create_snapshot("user", "1", now() - relativedelta(days=1), team_id=self.team.pk) + response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1") + assert response.status_code == status.HTTP_200_OK assert response.json()["storage"] == "object_storage" - # Trying to delete same recording again returns 404 + response = self.client.post(f"/api/projects/{self.team.id}/session_recordings/1/persist") - assert response.json()["success"] + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": True} + response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1") + assert response.status_code == status.HTTP_200_OK assert response.json()["storage"] == "object_storage_lts" # New snapshot loading method @freeze_time("2023-01-01T00:00:00Z") + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_get_snapshots_v2_default_response(self, mock_list_objects) -> None: + def test_get_snapshots_v2_default_response(self, mock_list_objects: MagicMock, _mock_exists: MagicMock) -> None: session_id = str(uuid.uuid4()) timestamp = round(now().timestamp() * 1000) mock_list_objects.return_value = [ @@ -610,33 +510,9 @@ def test_get_snapshots_v2_default_response(self, mock_list_objects) -> None: mock_list_objects.assert_called_with(f"session_recordings/team_id/{self.team.pk}/session_id/{session_id}/data") @freeze_time("2023-01-01T00:00:00Z") + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_get_snapshots_upgrade_to_v2_if_stored_recording_requires_it(self, mock_list_objects: MagicMock) -> None: - session_id = str(uuid.uuid4()) - timestamp = round(now().timestamp() * 1000) - mock_list_objects.return_value = [ - f"session_recordings/team_id/{self.team.pk}/session_id/{session_id}/data/{timestamp - 10000}-{timestamp - 5000}", - f"session_recordings/team_id/{self.team.pk}/session_id/{session_id}/data/{timestamp - 5000}-{timestamp}", - ] - - # if the recording has been written with a newer version, we have to upgrade to v2 - SessionRecording.objects.create(team=self.team, session_id=session_id, storage_version="2023-08-01") - - # add an unnecessary param to make sure we maintain params when redirecting - response = self.client.get( - f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?some-param=1" - ) - assert response.status_code == status.HTTP_302_FOUND - assert ( - response.headers["Location"] - == f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?some-param=1&version=2" - ) - - mock_list_objects.assert_not_called() - - @freeze_time("2023-01-01T00:00:00Z") - @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_get_snapshots_v2_from_lts(self, mock_list_objects: MagicMock) -> None: + def test_get_snapshots_v2_from_lts(self, mock_list_objects: MagicMock, _mock_exists: MagicMock) -> None: session_id = str(uuid.uuid4()) timestamp = round(now().timestamp() * 1000) @@ -662,6 +538,7 @@ def list_objects_func(path: str) -> List[str]: mock_list_objects.side_effect = list_objects_func response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?version=2") + assert response.status_code == 200 response_data = response.json() assert response_data == { @@ -691,8 +568,9 @@ def list_objects_func(path: str) -> List[str]: ] @freeze_time("2023-01-01T00:00:00Z") + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") - def test_get_snapshots_v2_default_response_no_realtime_if_old(self, mock_list_objects) -> None: + def test_get_snapshots_v2_default_response_no_realtime_if_old(self, mock_list_objects, _mock_exists) -> None: session_id = str(uuid.uuid4()) old_timestamp = round((now() - timedelta(hours=26)).timestamp() * 1000) @@ -713,11 +591,12 @@ def test_get_snapshots_v2_default_response_no_realtime_if_old(self, mock_list_ob ] } + @patch("posthog.session_recordings.queries.session_replay_events.SessionReplayEvents.exists", return_value=True) @patch("posthog.session_recordings.session_recording_api.SessionRecording.get_or_build") @patch("posthog.session_recordings.session_recording_api.object_storage.get_presigned_url") @patch("posthog.session_recordings.session_recording_api.requests") def test_can_get_session_recording_blob( - self, _mock_requests, mock_presigned_url, mock_get_session_recording + self, _mock_requests, mock_presigned_url, mock_get_session_recording, _mock_exists ) -> None: session_id = str(uuid.uuid4()) """API will add session_recordings/team_id/{self.team.pk}/session_id/{session_id}""" @@ -767,13 +646,10 @@ def test_can_not_get_session_recording_blob_that_does_not_exist(self, mock_presi response = self.client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND - @parameterized.expand( - [ - (False, 3), - (True, 1), - ] - ) - def test_get_via_sharing_token(self, use_recording_events: bool, api_version: int) -> None: + @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects") + def test_get_via_sharing_token(self, mock_copy_objects: MagicMock) -> None: + mock_copy_objects.return_value = 2 + other_team = create_team(organization=self.organization) session_id = str(uuid.uuid4()) @@ -783,7 +659,6 @@ def test_get_via_sharing_token(self, use_recording_events: bool, api_version: in session_id, now() - relativedelta(days=1), team_id=self.team.pk, - use_recording_table=use_recording_events, ) token = self.client.patch( @@ -816,9 +691,16 @@ def test_get_via_sharing_token(self, use_recording_events: bool, api_version: in "end_time": "2022-12-31T12:00:00Z", } - # if api_version is three then we should request snapshots with version 2 + # now create a snapshot record that doesn't have a fixed date, as it needs to be within TTL for the request below to complete + self.create_snapshot( + "user", + session_id, + now(), + team_id=self.team.pk, + ) + response = self.client.get( - f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?sharing_access_token={token}&version={api_version-1}" + f"/api/projects/{self.team.id}/session_recordings/{session_id}/snapshots?sharing_access_token={token}&version=2" ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -910,3 +792,11 @@ def test_get_matching_events(self) -> None: assert response.status_code == status.HTTP_200_OK assert response.json() == {"results": [event_id]} + + # checks that we 404 without patching the "exists" check + # that is patched in other tests or freezing time doesn't work + def test_404_when_no_snapshots(self) -> None: + response = self.client.get( + f"/api/projects/{self.team.id}/session_recordings/1/snapshots?version=2", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/posthog/settings/ingestion.py b/posthog/settings/ingestion.py index a970414f04fd1..bd9edbc6fb03c 100644 --- a/posthog/settings/ingestion.py +++ b/posthog/settings/ingestion.py @@ -30,14 +30,5 @@ "PARTITION_KEY_BUCKET_REPLENTISH_RATE", type_cast=float, default=1.0 ) -REPLAY_EVENT_MAX_SIZE = get_from_env("REPLAY_EVENT_MAX_SIZE", type_cast=int, default=1024 * 512) # 512kb -REPLAY_EVENTS_NEW_CONSUMER_RATIO = get_from_env("REPLAY_EVENTS_NEW_CONSUMER_RATIO", type_cast=float, default=0.0) - -if REPLAY_EVENTS_NEW_CONSUMER_RATIO > 1 or REPLAY_EVENTS_NEW_CONSUMER_RATIO < 0: - logger.critical( - "Environment variable REPLAY_EVENTS_NEW_CONSUMER_RATIO is not between 0 and 1. Setting to 0 to be safe." - ) - REPLAY_EVENTS_NEW_CONSUMER_RATIO = 0 - -REPLAY_RETENTION_DAYS_MIN = 30 -REPLAY_RETENTION_DAYS_MAX = 90 +REPLAY_RETENTION_DAYS_MIN = get_from_env("REPLAY_RETENTION_DAYS_MIN", type_cast=int, default=30) +REPLAY_RETENTION_DAYS_MAX = get_from_env("REPLAY_RETENTION_DAYS_MAX", type_cast=int, default=90) diff --git a/posthog/tasks/test/test_usage_report.py b/posthog/tasks/test/test_usage_report.py index 491d50c0bb57a..fa49c1f47e457 100644 --- a/posthog/tasks/test/test_usage_report.py +++ b/posthog/tasks/test/test_usage_report.py @@ -27,7 +27,7 @@ from posthog.models.plugin import PluginConfig from posthog.models.sharing_configuration import SharingConfiguration from posthog.schema import EventsQuery -from posthog.session_recordings.test.test_factory import create_snapshot +from posthog.session_recordings.queries.test.session_replay_sql import produce_replay_summary from posthog.tasks.usage_report import ( _get_all_org_reports, _get_all_usage_data_as_team_rows, @@ -225,49 +225,55 @@ def _create_sample_usage_data(self) -> None: # recordings in period - 5 sessions with 5 snapshots each for i in range(1, 6): for _ in range(0, 5): - create_snapshot( - has_full_snapshot=True, - distinct_id=distinct_id, - session_id=str(i), - timestamp=now() - relativedelta(hours=12), + session_id = str(i) + timestamp = now() - relativedelta(hours=12) + produce_replay_summary( team_id=self.org_1_team_2.id, + session_id=session_id, + distinct_id=distinct_id, + first_timestamp=timestamp, + last_timestamp=timestamp, ) # recordings out of period - 5 sessions with 5 snapshots each for i in range(1, 11): for _ in range(0, 5): - create_snapshot( - has_full_snapshot=True, - distinct_id=distinct_id, - session_id=str(i + 10), - timestamp=now() - relativedelta(hours=48), + id1 = str(i + 10) + timestamp1 = now() - relativedelta(hours=48) + produce_replay_summary( team_id=self.org_1_team_2.id, + session_id=id1, + distinct_id=distinct_id, + first_timestamp=timestamp1, + last_timestamp=timestamp1, ) # ensure there is a recording that starts before the period and ends during the period # report is going to be for "yesterday" relative to the test so... start_of_day = datetime.combine(now().date(), datetime.min.time()) - relativedelta(days=1) session_that_will_not_match = "session-that-will-not-match-because-it-starts-before-the-period" - create_snapshot( - has_full_snapshot=True, - distinct_id=distinct_id, - session_id=session_that_will_not_match, - timestamp=start_of_day - relativedelta(hours=1), + timestamp2 = start_of_day - relativedelta(hours=1) + produce_replay_summary( team_id=self.org_1_team_2.id, - ) - create_snapshot( - has_full_snapshot=False, - distinct_id=distinct_id, session_id=session_that_will_not_match, - timestamp=start_of_day, - team_id=self.org_1_team_2.id, - ) - create_snapshot( - has_full_snapshot=False, distinct_id=distinct_id, + first_timestamp=timestamp2, + last_timestamp=timestamp2, + ) + produce_replay_summary( + team_id=self.org_1_team_2.id, session_id=session_that_will_not_match, - timestamp=start_of_day + relativedelta(hours=1), + distinct_id=distinct_id, + first_timestamp=start_of_day, + last_timestamp=start_of_day, + ) + timestamp3 = start_of_day + relativedelta(hours=1) + produce_replay_summary( team_id=self.org_1_team_2.id, + session_id=session_that_will_not_match, + distinct_id=distinct_id, + first_timestamp=timestamp3, + last_timestamp=timestamp3, ) _create_event( distinct_id=distinct_id,