Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: canvas replay #19583

Merged
merged 29 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4c97723
feat: canvas replay
daibhin Dec 28, 2023
f26b5a8
Merge branch 'master' into dn-feat/canvas-replay
daibhin Dec 29, 2023
e142002
allow canvas replay
daibhin Dec 29, 2023
b31cfe0
implement canvas image snapshotting
daibhin Jan 2, 2024
11dd3d1
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 2, 2024
74fda4c
replicate unimported files
daibhin Jan 2, 2024
bf2fd51
remove unnecessary replay flag
daibhin Jan 3, 2024
0cf8814
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 3, 2024
6f695be
add feature flagged canvas recording settings
daibhin Jan 3, 2024
cd3bc4a
include settings migration
daibhin Jan 3, 2024
e98434b
tdd decide response
daibhin Jan 3, 2024
ff770d3
cleanup canvasMutation import
daibhin Jan 3, 2024
272fb38
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 18, 2024
c35814b
update migration
daibhin Jan 18, 2024
aad21d8
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 19, 2024
8fde514
canvas playback
daibhin Jan 19, 2024
5cfd8ac
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 22, 2024
03f508e
cleanup
daibhin Jan 22, 2024
0087042
add new label
daibhin Jan 22, 2024
d4e5183
rename column
daibhin Jan 22, 2024
5855334
fix tests
daibhin Jan 22, 2024
ea9d7db
Merge branch 'master' into dn-feat/canvas-replay
daibhin Jan 22, 2024
d6a7660
make new badge success
daibhin Jan 22, 2024
e0993a9
update snapshot
daibhin Jan 22, 2024
38bfddd
Update query snapshots
github-actions[bot] Jan 22, 2024
fadd150
Update query snapshots
github-actions[bot] Jan 22, 2024
4a7e848
Update query snapshots
github-actions[bot] Jan 22, 2024
ea4d85a
Update query snapshots
github-actions[bot] Jan 22, 2024
caa2c4d
Update query snapshots
github-actions[bot] Jan 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/@types/rrweb.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'rrweb/es/rrweb/packages/rrweb/src/replay/canvas'
1 change: 1 addition & 0 deletions frontend/src/lib/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const MOCK_DEFAULT_TEAM: TeamType = {
session_recording_minimum_duration_milliseconds: null,
session_recording_linked_flag: null,
session_recording_network_payload_capture_config: null,
session_replay_config: null,
capture_console_log_opt_in: true,
capture_performance_opt_in: true,
autocapture_exceptions_opt_in: false,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export const FEATURE_FLAGS = {
SESSION_REPLAY_IOS: 'session-replay-ios', // owner: #team-replay
YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay
SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay
SESSION_REPLAY_CANVAS: 'session-replay-canvas', // owner: #team-replay
DISCUSSIONS: 'discussions', // owner: #team-replay
REDIRECT_WEB_PRODUCT_ANALYTICS_ONBOARDING: 'redirect-web-product-analytics-onboarding', // owner: @biancayang
RECRUIT_ANDROID_MOBILE_BETA_TESTERS: 'recruit-android-mobile-beta-testers', // owner: #team-replay
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1714,11 +1714,20 @@ export const base64Encode = (str: string): string => {
}

export const base64Decode = (encodedString: string): string => {
const data = base64ToUint8Array(encodedString)
return new TextDecoder().decode(data)
}

export const base64ArrayBuffer = (encodedString: string): ArrayBuffer => {
const data = base64ToUint8Array(encodedString)
return data.buffer
}

export const base64ToUint8Array = (encodedString: string): Uint8Array => {
const binString = atob(encodedString)
const data = new Uint8Array(binString.length)
for (let i = 0; i < binString.length; i++) {
data[i] = binString.charCodeAt(i)
}

return new TextDecoder().decode(data)
return data
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { CanvasArg, canvasMutationData, canvasMutationParam, eventWithTime } from '@rrweb/types'
import { EventType, IncrementalSource, Replayer } from 'rrweb'
import { canvasMutation } from 'rrweb/es/rrweb/packages/rrweb/src/replay/canvas'
import { ReplayPlugin } from 'rrweb/typings/types'

import { deserializeCanvasArg } from './deserialize-canvas-args'

export const CanvasReplayerPlugin = (events: eventWithTime[]): ReplayPlugin => {
const canvases = new Map<number, HTMLCanvasElement>([])
const containers = new Map<number, HTMLImageElement>([])
const imageMap = new Map<eventWithTime | string, HTMLImageElement>()
const canvasEventMap = new Map<eventWithTime | string, canvasMutationParam>()

const deserializeAndPreloadCanvasEvents = async (data: canvasMutationData, event: eventWithTime): Promise<void> => {
if (!canvasEventMap.has(event)) {
const status = { isUnchanged: true }

if ('commands' in data) {
const commands = await Promise.all(
data.commands.map(async (c) => {
const args = await Promise.all(
(c.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status))
)
return { ...c, args }
})
)
if (status.isUnchanged === false) {
canvasEventMap.set(event, { ...data, commands })
}
} else {
const args = await Promise.all(
(data.args as CanvasArg[]).map(deserializeCanvasArg(imageMap, null, status))
)
if (status.isUnchanged === false) {
canvasEventMap.set(event, { ...data, args })
}
}
}
}

const cloneCanvas = (id: number, node: HTMLCanvasElement): HTMLCanvasElement => {
const cloneNode = node.cloneNode() as HTMLCanvasElement
canvases.set(id, cloneNode)
document.adoptNode(cloneNode)
return cloneNode
}

const promises: Promise<any>[] = []
for (const event of events) {
if (event.type === EventType.IncrementalSnapshot && event.data.source === IncrementalSource.CanvasMutation) {
promises.push(deserializeAndPreloadCanvasEvents(event.data, event))
}
}

return {
onBuild: (node, { id }) => {
if (!node) {
return
}

if (node.nodeName === 'CANVAS' && node.nodeType === 1) {
const el = containers.get(id) || document.createElement('img')
;(node as HTMLCanvasElement).appendChild(el)
containers.set(id, el)
}
},

// eslint-disable-next-line @typescript-eslint/no-misused-promises
handler: async (e: eventWithTime, _isSync: boolean, { replayer }: { replayer: Replayer }) => {
if (e.type === EventType.IncrementalSnapshot && e.data.source === IncrementalSource.CanvasMutation) {
const source = replayer.getMirror().getNode(e.data.id)
const target =
canvases.get(e.data.id) || (source && cloneCanvas(e.data.id, source as HTMLCanvasElement))

if (!target) {
return
}

await canvasMutation({
event: e,
mutation: e.data,
target: target,
imageMap,
canvasEventMap,
})

const img = containers.get(e.data.id)
if (img) {
img.src = target.toDataURL()
}
}
},
} as ReplayPlugin
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CanvasArg } from '@rrweb/types'
import { base64ArrayBuffer } from 'lib/utils'
import { Replayer } from 'rrweb'

type GLVarMap = Map<string, any[]>
type CanvasContexts = CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext
const webGLVarMap: Map<CanvasContexts, GLVarMap> = new Map()

const variableListFor = (ctx: CanvasContexts, ctor: string): any[] => {
let contextMap = webGLVarMap.get(ctx)
if (!contextMap) {
contextMap = new Map()
webGLVarMap.set(ctx, contextMap)
}
if (!contextMap.has(ctor)) {
contextMap.set(ctor, [])
}

return contextMap.get(ctor) as any[]
}

export const deserializeCanvasArg = (
imageMap: Replayer['imageMap'],
ctx: CanvasContexts | null,
preload?: {
isUnchanged: boolean
}
): ((arg: CanvasArg) => Promise<any>) => {
return async (arg: CanvasArg): Promise<any> => {
if (arg && typeof arg === 'object' && 'rr_type' in arg) {
if (preload) {
preload.isUnchanged = false
}
if (arg.rr_type === 'ImageBitmap' && 'args' in arg) {
const args = await deserializeCanvasArg(imageMap, ctx, preload)(arg.args)
// eslint-disable-next-line prefer-spread
return await createImageBitmap.apply(null, args)
}
if ('index' in arg) {
if (preload || ctx === null) {
return arg
}
const { rr_type: name, index } = arg
return variableListFor(ctx, name)[index]
}
if ('args' in arg) {
return arg
}
if ('base64' in arg) {
return base64ArrayBuffer(arg.base64)
}
if ('src' in arg) {
return arg
}
if ('data' in arg && arg.rr_type === 'Blob') {
const blobContents = await Promise.all(arg.data.map(deserializeCanvasArg(imageMap, ctx, preload)))
const blob = new Blob(blobContents, {
type: arg.type,
})
return blob
}
} else if (Array.isArray(arg)) {
const result = await Promise.all(arg.map(deserializeCanvasArg(imageMap, ctx, preload)))
return result
}
return arg
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { createExportedSessionRecording } from '../file-playback/sessionRecordin
import type { sessionRecordingsPlaylistLogicType } from '../playlist/sessionRecordingsPlaylistLogicType'
import { playerSettingsLogic } from './playerSettingsLogic'
import { COMMON_REPLAYER_CONFIG, CorsPlugin } from './rrweb'
import { CanvasReplayerPlugin } from './rrweb/canvas/canvas-plugin'
import type { sessionRecordingPlayerLogicType } from './sessionRecordingPlayerLogicType'
import { deleteRecording } from './utils/playerUtils'
import { SessionRecordingPlayerExplorerProps } from './view-explorer/SessionRecordingPlayerExplorer'
Expand Down Expand Up @@ -526,6 +527,10 @@ export const sessionRecordingPlayerLogic = kea<sessionRecordingPlayerLogicType>(
plugins.push(CorsPlugin)
}

if (values.featureFlags[FEATURE_FLAGS.SESSION_REPLAY_CANVAS]) {
Copy link
Member

Choose a reason for hiding this comment

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

is it possible to get in a state where I'm collecting canvas recordings but this flag is off?

not blocking here but we should have a good story for "this replay has a canvas in it"

Copy link
Member

Choose a reason for hiding this comment

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

also makes me think it'd be good to have a PostHog insight showing web vs android vs canvas vs etc playback so we can measure feature usage

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, possibly could be. We should also consider in future that recordings might be captured with canvas and then the config option is turned off. Should we continue to playback recordings with the canvas or not in that situation 🤔

Good news is that it's pretty easy to know if a recording contains a canvas in the rrweb plugin during playback

plugins.push(CanvasReplayerPlugin(values.sessionPlayerData.snapshotsByWindowId[windowId]))
}

cache.debug?.('tryInitReplayer', {
windowId,
rootFrame: values.rootFrame,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ exports[`verifiedDomainsLogic values has proper defaults 1`] = `
"session_recording_network_payload_capture_config": null,
"session_recording_opt_in": true,
"session_recording_sample_rate": "1.0",
"session_replay_config": null,
"slack_incoming_webhook": "",
"test_account_filters": [
{
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/scenes/settings/project/SessionRecordingSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { LemonButton, LemonSelect, LemonSwitch, Link } from '@posthog/lemon-ui'
import { LemonButton, LemonSelect, LemonSwitch, LemonTag, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { AuthorizedUrlList } from 'lib/components/AuthorizedUrlList/AuthorizedUrlList'
import { AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { FlaggedFeature } from 'lib/components/FlaggedFeature'
import { FlagSelector } from 'lib/components/FlagSelector'
import { FEATURE_FLAGS, SESSION_REPLAY_MINIMUM_DURATION_OPTIONS } from 'lib/constants'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { IconCancel } from 'lib/lemon-ui/icons'
import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
Expand All @@ -16,8 +17,8 @@ import { AvailableFeature } from '~/types'

export function ReplayGeneral(): JSX.Element {
const { updateCurrentTeam } = useActions(teamLogic)

const { currentTeam } = useValues(teamLogic)
const hasCanvasRecording = useFeatureFlag('SESSION_REPLAY_CANVAS')

return (
<div className="space-y-4">
Expand Down Expand Up @@ -73,6 +74,42 @@ export function ReplayGeneral(): JSX.Element {
logs will be shown in the recording player to help you debug any issues.
</p>
</div>
{hasCanvasRecording && (
<div className="space-y-2">
<LemonSwitch
data-attr="opt-in-capture-canvas-switch"
onChange={(checked) => {
updateCurrentTeam({
session_replay_config: {
...currentTeam?.session_replay_config,
record_canvas: checked,
},
})
}}
label={
<div className="space-x-1">
<LemonTag type="success">New</LemonTag>
<LemonLabel>Capture canvas elements</LemonLabel>
</div>
}
bordered
checked={
currentTeam?.session_replay_config
? !!currentTeam?.session_replay_config?.record_canvas
: false
}
/>
<p>
This setting controls if browser canvas elements will be captured as part of recordings.{' '}
<b>
<i>
There is no way to mask canvas elements right now so please make sure they are free of
PII.
</i>
</b>
</p>
</div>
)}
<div className="space-y-2">
<NetworkCaptureSettings />
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ export interface TeamType extends TeamBasicType {
| { recordHeaders?: boolean; recordBody?: boolean }
| undefined
| null
session_replay_config: { record_canvas?: boolean } | undefined | null
autocapture_exceptions_opt_in: boolean
surveys_opt_in?: boolean
autocapture_exceptions_errors_to_ignore: string[]
Expand Down
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const config: Config = {
'^scenes/(.*)$': '<rootDir>/frontend/src/scenes/$1',
'^antd/es/(.*)$': 'antd/lib/$1',
'^react-virtualized/dist/es/(.*)$': 'react-virtualized/dist/commonjs/$1',
'^rrweb/es/rrweb': 'rrweb/dist/rrweb.min.js',
d3: '<rootDir>/node_modules/d3/dist/d3.min.js',
'^d3-(.*)$': `d3-$1/dist/d3-$1`,
},
Expand Down
2 changes: 1 addition & 1 deletion latest_migrations.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ contenttypes: 0002_remove_content_type_name
ee: 0015_add_verified_properties
otp_static: 0002_throttling
otp_totp: 0002_auto_20190420_0723
posthog: 0385_exception_autocapture_off_for_all
posthog: 0386_add_session_replay_config_to_team
sessions: 0001_initial
social_django: 0010_uid_db_index
two_factor: 0007_auto_20201201_1019
15 changes: 14 additions & 1 deletion posthog/api/decide.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def get_decide(request: HttpRequest):
if isinstance(linked_flag, Dict):
linked_flag = linked_flag.get("key")

response["sessionRecording"] = {
session_recording_response = {
"endpoint": "/s/",
"consoleLogRecordingEnabled": capture_console_logs,
"recorderVersion": "v2",
Expand All @@ -267,6 +267,19 @@ def get_decide(request: HttpRequest):
"networkPayloadCapture": team.session_recording_network_payload_capture_config or None,
}

if isinstance(team.session_replay_config, Dict):
record_canvas = team.session_replay_config["record_canvas"] or False
session_recording_response.update(
{
"recordCanvas": record_canvas,
# hard coded during beta while we decide on sensible values
"canvasFps": 4 if record_canvas else None,
"canvasQuality": "0.6" if record_canvas else None,
}
)

response["sessionRecording"] = session_recording_response

response["surveys"] = True if team.surveys_opt_in else False

site_apps = []
Expand Down
14 changes: 14 additions & 0 deletions posthog/api/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class Meta:
"session_recording_minimum_duration_milliseconds",
"session_recording_linked_flag",
"session_recording_network_payload_capture_config",
"session_replay_config",
"recording_domains",
"inject_web_apps",
"surveys_opt_in",
Expand Down Expand Up @@ -146,6 +147,7 @@ class Meta:
"session_recording_minimum_duration_milliseconds",
"session_recording_linked_flag",
"session_recording_network_payload_capture_config",
"session_replay_config",
"effective_membership_level",
"access_control",
"week_start_day",
Expand Down Expand Up @@ -208,6 +210,18 @@ def validate_session_recording_network_payload_capture_config(self, value) -> Di

return value

def validate_session_replay_config(self, value) -> Dict | None:
if value is None:
return None

if not isinstance(value, Dict):
raise exceptions.ValidationError("Must provide a dictionary or None.")

if not all(key in ["record_canvas"] for key in value.keys()):
raise exceptions.ValidationError("Must provide a dictionary with only 'record_canvas' key.")
Comment on lines +220 to +221
Copy link
Member

Choose a reason for hiding this comment

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

Maybe it should not be a dictionary then?

Copy link
Member

Choose a reason for hiding this comment

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

Ah, I'd forgotten my pre-xmas good citizen push that we should move session replay config into one place so we can stop adding more nad more columns to the team model

Maybe we should get this in and then I use it for the config in #19887


return value

def validate(self, attrs: Any) -> Any:
if "primary_dashboard" in attrs and attrs["primary_dashboard"].team != self.instance:
raise exceptions.PermissionDenied("Dashboard does not belong to this team.")
Expand Down
Loading
Loading