diff --git a/.changeset/hifi-session-recording.md b/.changeset/hifi-session-recording.md new file mode 100644 index 0000000000..1d8132b618 --- /dev/null +++ b/.changeset/hifi-session-recording.md @@ -0,0 +1,5 @@ +--- +"jspsych": minor +--- + +Add `record_session` option to `initJsPsych` for high-fidelity replay capture. When enabled, `jsPsych.getSessionRecording()` returns a JSON-serializable `SessionRecording` (`schema_version: 1`) capturing per-trial DOM snapshots and mutations, mouse/touch/keyboard/clipboard/scroll/form input, media events, viewport changes, stylesheets, canvas pixel snapshots, and every `Math.random()` output. `jsPsych.getSessionRecordingCompressed()` returns the same data as a gzip Blob. Default is `false`; opt-in only. diff --git a/docs/reference/jspsych.md b/docs/reference/jspsych.md index b7a25dc6fa..31e2dc8c56 100644 --- a/docs/reference/jspsych.md +++ b/docs/reference/jspsych.md @@ -34,7 +34,7 @@ The settings object can contain several parameters. None of the parameters are r | override_safe_mode | boolean | Running a jsPsych experiment directly in a web browser (e.g., by double clicking on a local HTML file) will load the page using the `file://` protocol. Some features of jsPsych don't work with this protocol. By default, when jsPsych detects that it's running on a page loaded via the `file://` protocol, it runs in _safe mode_, which automatically disables features that don't work in this context. Specifically, the use of Web Audio is disabled (audio will be played using HTML5 audio instead, even if `use_webaudio` is `true`) and video preloading is disabled. The `override_safe_mode` parameter defaults to `false`, but you can set it to `true` to force these features to operate under the `file://` protocol. In order for this to work, you will need to disable web security (CORS) features in your browser - this is safe to do if you know what you are doing. Note that this parameter has no effect when you are running the experiment on a web server, because the page will be loaded via the `http://` or `https://` protocol. | | case_sensitive_responses | boolean | If `true`, then jsPsych will make a distinction between uppercase and lowercase keys when evaluating keyboard responses, e.g. "A" (uppercase) will not be recognized as a valid response if the trial only accepts "a" (lowercase). If false, then jsPsych will not make a distinction between uppercase and lowercase keyboard responses, e.g. both "a" and "A" responses will be valid when the trial's key choice parameter is "a". Setting this parameter to false is useful if you want key responses to be treated the same way when CapsLock is turned on or the Shift key is held down. The default value is `false`. | extensions | array | Array containing information about one or more jsPsych extensions that are used during the experiment. Each extension should be specified as an object with `type` (required), which is the name of the extension, and `params` (optional), which is an object containing any parameter-value pairs to be passed to the extension's `initialize` function. Default value is an empty array. | -| record_session | boolean | If `true`, jsPsych captures a high-fidelity recording of the session — DOM mutations within the display element, mouse/touch/keyboard/clipboard input, scroll position (window and per-element), video and audio playback events, viewport changes (including pinch zoom), and every `Math.random()` output — sufficient to reconstruct a replay of what the participant saw and did. The recording is retrieved at the end of the experiment via `jsPsych.getSessionRecording()` and can be `JSON.stringify`'d and saved alongside the trial data. While enabled, `Math.random` is wrapped to log every call into the recording; this is reverted when the experiment ends. The default value is `false`. **Note:** text typed into form inputs (e.g. survey responses) is captured verbatim. Inform participants accordingly. | +| record_session | boolean \| object | If `true`, jsPsych captures a high-fidelity recording of the session — DOM mutations within the display element, mouse/touch/keyboard/clipboard input, scroll position (window and per-element), video and audio playback events, viewport changes (including pinch zoom), and every `Math.random()` output — sufficient to reconstruct a replay of what the participant saw and did. The recording is retrieved at the end of the experiment via `jsPsych.getSessionRecording()` and can be `JSON.stringify`'d and saved alongside the trial data. While enabled, `Math.random` is wrapped to log every call into the recording; this is reverted when the experiment ends. The default value is `false`. Pass an object instead of `true` to opt out of categories or bound memory: `capture_inputs` (default `true`; set `false` for surveys whose responses must not be retained verbatim), `capture_canvas` (default `true`; set `false` to skip canvas pixel snapshots), `capture_random` (default `true`; set `false` when the RNG is called millions of times and reproducibility isn't needed), and `max_events` (default unlimited; when exceeded, recording stops with `end_reason: "memory_limit"`). **Note:** with `capture_inputs: true`, text typed into form inputs (e.g. survey responses) is captured verbatim. Inform participants accordingly. | ### Return value diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 9139bf11c5..68cc06b3d5 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -9,7 +9,11 @@ import { JsPsychExtension } from "./modules/extensions"; import { PluginAPI, createJointPluginAPIObject } from "./modules/plugin-api"; import { JsPsychPlugin } from "./modules/plugins"; import * as randomization from "./modules/randomization"; -import { SessionRecorder, SessionRecording } from "./modules/recording"; +import { + SessionRecorder, + SessionRecording, + resolveRecordSessionOptions, +} from "./modules/recording"; import * as turk from "./modules/turk"; import * as utils from "./modules/utils"; import { ProgressBar } from "./ProgressBar"; @@ -94,8 +98,9 @@ export class JsPsych { }; this.options = options; - if (options.record_session) { - this.sessionRecorder = new SessionRecorder({ jspsychVersion: version }); + const recordOptions = resolveRecordSessionOptions(options.record_session); + if (recordOptions) { + this.sessionRecorder = new SessionRecorder({ jspsychVersion: version, recordOptions }); } autoBind(this); // so we can pass JsPsych methods as callbacks and `this` remains the JsPsych instance @@ -237,44 +242,22 @@ export class JsPsych { * } * ``` * - * @example Offer the participant a download: - * ```ts - * const blob = await jsPsych.getSessionRecordingCompressed(); - * if (blob) { - * const a = document.createElement("a"); - * a.href = URL.createObjectURL(blob); - * a.download = "session.json.gz"; - * a.click(); - * URL.revokeObjectURL(a.href); - * } - * ``` - * - * @example Stash in jsPsych's data record (base64-encoded): - * ```ts - * const blob = await jsPsych.getSessionRecordingCompressed(); - * if (blob) { - * const buf = new Uint8Array(await blob.arrayBuffer()); - * let binary = ""; - * for (const byte of buf) binary += String.fromCharCode(byte); - * jsPsych.data.addProperties({ session_recording_b64: btoa(binary) }); - * } - * ``` */ async getSessionRecordingCompressed(): Promise { const recording = this.getSessionRecording(); if (!recording) return undefined; - // Drive the compression stream directly via its writer/reader so the - // implementation depends only on `TextEncoder`, `CompressionStream`, - // and `Blob` — all available in evergreen browsers (Chrome 80, - // Firefox 113, Safari 16.4) and in Node 18+. + // Run the writer and reader sides of the gzip stream concurrently: + // for large recordings, awaiting the write before opening the + // reader would stall on backpressure, while not awaiting it at all + // means write/close errors surface as unhandled rejections. The + // pattern below propagates those errors into the returned promise. const json = JSON.stringify(recording); const cs = new CompressionStream("gzip"); const writer = cs.writable.getWriter(); - // Don't await the writer; the reader loop below pulls chunks out as - // the writer pushes them in. Awaiting first would deadlock on - // backpressure for large recordings. - writer.write(new TextEncoder().encode(json)); - writer.close(); + const writePromise = (async () => { + await writer.write(new TextEncoder().encode(json)); + await writer.close(); + })(); const reader = cs.readable.getReader(); const chunks: Uint8Array[] = []; for (;;) { @@ -282,6 +265,7 @@ export class JsPsych { if (done) break; chunks.push(value); } + await writePromise; return new Blob(chunks, { type: "application/gzip" }); } diff --git a/packages/jspsych/src/index.ts b/packages/jspsych/src/index.ts index 15e13fe7d5..f976430822 100755 --- a/packages/jspsych/src/index.ts +++ b/packages/jspsych/src/index.ts @@ -66,3 +66,22 @@ export type { JsPsychPlugin, PluginInfo, TrialType } from "./modules/plugins"; export { ParameterType } from "./modules/plugins"; export type { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions"; export { DataCollection } from "./modules/data/DataCollection"; +export type { + CanvasSnapshot, + ClipboardRecord, + DomMutation, + DomNode, + FocusRecord, + InputRecord, + MediaRecord, + RecordedEvent, + RecordSessionOptions, + RngCall, + ScrollRecord, + SessionRecording, + StylesheetEvent, + StylesheetSnapshot, + TrialRecording, + ViewportChange, + ViewportState, +} from "./modules/recording"; diff --git a/packages/jspsych/src/modules/recording.ts b/packages/jspsych/src/modules/recording/SessionRecorder.ts similarity index 55% rename from packages/jspsych/src/modules/recording.ts rename to packages/jspsych/src/modules/recording/SessionRecorder.ts index 727c544cd6..d21e8fd1c1 100644 --- a/packages/jspsych/src/modules/recording.ts +++ b/packages/jspsych/src/modules/recording/SessionRecorder.ts @@ -1,263 +1,56 @@ -import * as randomization from "./randomization"; - -// --------------------------------------------------------------------------- -// Schema types (schema_version: 1) -// --------------------------------------------------------------------------- - -export type JsonValue = - | null - | boolean - | number - | string - | JsonValue[] - | { [key: string]: JsonValue }; - -export interface SessionRecording { - schema_version: 1; - jspsych_version: string; - recording_started_at: string; - recording_started_at_perf: number; - user_agent: string; - viewport: ViewportState; - rng: { seed: string | null; math_random_patched: boolean }; - display_element_id: string; - stylesheets: StylesheetSnapshot[]; - // Chronological log of `` stylesheet mutations after `start()`. - // Initial state is in `stylesheets`; this records subsequent additions, - // removals, and `