-
Notifications
You must be signed in to change notification settings - Fork 513
session replays #1187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
session replays #1187
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
2dcb935
feat: session recording database schema, S3 utilities, and batch uplo…
BilalG1 c62446b
feat: client-side session recording SDK with rrweb capture
BilalG1 6799c8e
feat: admin SDK methods and internal API endpoints for session record…
BilalG1 f991184
feat: dashboard replay viewer for multi-tab session recordings
BilalG1 3f3f8e8
small
BilalG1 5092434
replay frontend fix
BilalG1 2a590e6
record pre-login
BilalG1 52a4388
Merge branch 'dev' into analytics-replays-1
BilalG1 9f99dcf
Merge branch 'analytics-replays-1' into analytics-replays-2
BilalG1 f0e07e9
Merge branch 'analytics-replays-2' into analytics-replays-3
BilalG1 7d9925a
Merge branch 'analytics-replays-3' into analytics-replays-4
BilalG1 a18fa97
stack s3 private key
BilalG1 dad51aa
upsert session by refresh token
BilalG1 05e4ade
fix
BilalG1 ddcf529
Merge branch 'dev' into analytics-replays-1
BilalG1 f098e96
Merge branch 'analytics-replays-1' into analytics-replays-2
BilalG1 0cd4301
Merge branch 'analytics-replays-2' into analytics-replays-3
BilalG1 c4d6503
Merge branch 'analytics-replays-3' into analytics-replays-4
BilalG1 6e42342
sessions by refresh token, disable replays by default
BilalG1 659c561
fix lint
BilalG1 36ab8ff
remove sot test
BilalG1 3660f10
Merge remote-tracking branch 'origin/dev' into analytics-replays-4
BilalG1 c312176
improve chunk fetching logic
BilalG1 5e1e828
max session time, improved replayer testing
BilalG1 278a745
grouped chunk event fetching
BilalG1 175ac9d
fix tests
BilalG1 0a1097d
Merge branch 'dev' into analytics-replays-4
BilalG1 3d51c70
js replays
BilalG1 5868a35
small pr fixes
BilalG1 fa3242e
fix stuck replayer bug
BilalG1 b9e9047
handle analytics app disabled in event ingestion
BilalG1 6f641fa
Merge branch 'dev' into analytics-replays-4
BilalG1 e6b0cd0
merge dev
BilalG1 e1c9d8e
Merge branch 'dev' into analytics-replays-4
BilalG1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Oh My
Sorry... we’re unable to render the document prior to diffing.
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
apps/backend/prisma/migrations/20260210120000_session_recordings_mvp/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| -- Session recording MVP: store session metadata in Postgres and rrweb events in S3. | ||
|
|
||
| CREATE TABLE "SessionRecording" ( | ||
| "id" UUID NOT NULL, | ||
| "tenancyId" UUID NOT NULL, | ||
| "projectUserId" UUID NOT NULL, | ||
| "refreshTokenId" UUID NOT NULL, | ||
| "startedAt" TIMESTAMP(3) NOT NULL, | ||
| "lastEventAt" TIMESTAMP(3) NOT NULL, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
| CONSTRAINT "SessionRecording_pkey" PRIMARY KEY ("tenancyId","id") | ||
| ); | ||
|
|
||
| CREATE TABLE "SessionRecordingChunk" ( | ||
| "id" UUID NOT NULL, | ||
| "tenancyId" UUID NOT NULL, | ||
| "sessionRecordingId" UUID NOT NULL, | ||
| "batchId" UUID NOT NULL, | ||
| "tabId" TEXT NOT NULL, | ||
| "browserSessionId" TEXT NOT NULL, | ||
| "s3Key" TEXT NOT NULL, | ||
| "eventCount" INTEGER NOT NULL, | ||
| "byteLength" INTEGER NOT NULL, | ||
| "firstEventAt" TIMESTAMP(3) NOT NULL, | ||
| "lastEventAt" TIMESTAMP(3) NOT NULL, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| CONSTRAINT "SessionRecordingChunk_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| ALTER TABLE "SessionRecording" | ||
| ADD CONSTRAINT "SessionRecording_tenancyId_fkey" | ||
| FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| ALTER TABLE "SessionRecording" | ||
| ADD CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey" | ||
| FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| ALTER TABLE "SessionRecordingChunk" | ||
| ADD CONSTRAINT "SessionRecordingChunk_tenancyId_fkey" | ||
| FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| ALTER TABLE "SessionRecordingChunk" | ||
| ADD CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey" | ||
| FOREIGN KEY ("tenancyId","sessionRecordingId") REFERENCES "SessionRecording"("tenancyId","id") ON DELETE CASCADE ON UPDATE CASCADE; | ||
|
|
||
| CREATE INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" | ||
| ON "SessionRecording"("tenancyId", "projectUserId", "startedAt"); | ||
|
|
||
| CREATE INDEX "SessionRecording_tenancyId_lastEventAt_idx" | ||
| ON "SessionRecording"("tenancyId", "lastEventAt"); | ||
|
|
||
| CREATE INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx" | ||
| ON "SessionRecording"("tenancyId", "refreshTokenId", "updatedAt"); | ||
|
|
||
| CREATE UNIQUE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key" | ||
| ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "batchId"); | ||
|
|
||
| CREATE INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx" | ||
| ON "SessionRecordingChunk"("tenancyId", "sessionRecordingId", "createdAt"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
...est/internal/session-recordings/[session_recording_id]/chunks/[chunk_id]/events/route.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { getPrismaClientForTenancy } from "@/prisma-client"; | ||
| import { downloadBytes } from "@/s3"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { KnownErrors } from "@stackframe/stack-shared"; | ||
| import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { promisify } from "node:util"; | ||
| import { gunzip as gunzipCb } from "node:zlib"; | ||
|
|
||
| const gunzip = promisify(gunzipCb); | ||
|
|
||
| export const GET = createSmartRouteHandler({ | ||
| metadata: { hidden: true }, | ||
| request: yupObject({ | ||
| auth: yupObject({ | ||
| type: adminAuthTypeSchema.defined(), | ||
| tenancy: adaptSchema.defined(), | ||
| }).defined(), | ||
| params: yupObject({ | ||
| session_recording_id: yupString().defined(), | ||
| chunk_id: yupString().defined(), | ||
| }).defined(), | ||
| }), | ||
| response: yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| events: yupArray(yupMixed().defined()).defined(), | ||
| }).defined(), | ||
| }), | ||
| async handler({ auth, params }) { | ||
| const prisma = await getPrismaClientForTenancy(auth.tenancy); | ||
|
|
||
| const sessionRecordingId = params.session_recording_id; | ||
| const chunkId = params.chunk_id; | ||
|
|
||
| const chunk = await prisma.sessionRecordingChunk.findFirst({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| sessionRecordingId, | ||
| id: chunkId, | ||
| }, | ||
| select: { | ||
| s3Key: true, | ||
| }, | ||
| }); | ||
| if (!chunk) { | ||
| throw new KnownErrors.ItemNotFound(chunkId); | ||
| } | ||
|
|
||
| let bytes: Uint8Array; | ||
| try { | ||
| bytes = await downloadBytes({ key: chunk.s3Key, private: true }); | ||
| } catch (e: any) { | ||
| const status = e?.$metadata?.httpStatusCode; | ||
| if (status === 404) { | ||
| throw new KnownErrors.ItemNotFound(chunkId); | ||
| } | ||
| throw e; | ||
| } | ||
| const unzipped = new Uint8Array(await gunzip(bytes)); | ||
|
|
||
| let parsed: any; | ||
| try { | ||
| parsed = JSON.parse(new TextDecoder().decode(unzipped)); | ||
| } catch (e) { | ||
| throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e }); | ||
| } | ||
|
|
||
| if (typeof parsed !== "object" || parsed === null) { | ||
| throw new StackAssertionError("Decoded session recording chunk is not an object"); | ||
| } | ||
| if (parsed.session_recording_id !== sessionRecordingId) { | ||
| throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", { | ||
| expected: sessionRecordingId, | ||
| actual: parsed.session_recording_id, | ||
| }); | ||
| } | ||
| if (!Array.isArray(parsed.events)) { | ||
| throw new StackAssertionError("Decoded session recording chunk events is not an array"); | ||
| } | ||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| events: parsed.events, | ||
| }, | ||
| }; | ||
| }, | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.