@lookit/lookit-initjspsych
Version:
This package overloads jsPsych's init function.
277 lines (259 loc) • 10 kB
text/typescript
import Api from "@lookit/data";
import {
ChsRecordingData,
JsPsychExpData,
LookitWindow,
} from "@lookit/data/dist/types";
import chsTemplates from "@lookit/templates";
import { DataCollection, JsPsych } from "jspsych";
import { NoJsPsychInstanceError } from "./errors";
import { UserFuncOnDataUpdate, UserFuncOnFinish } from "./types";
declare let window: LookitWindow;
/**
* Minimal shape of the session recorder stored in window.chs.sessionRecorder
* that this wrapper needs. Declared locally to avoid a dependency on the record
* package (which is what actually constructs it).
*/
interface ChsSessionRecorder {
getSessionTrialRecordingData: () => ChsRecordingData;
}
/**
* If a session (cross-trial) recording is currently active, capture its
* per-trial recording data (filename, is_session_recording, stream time) at the
* current moment. Intended to be called at the start of each trial, so the
* captured stream time reflects the trial's start.
*
* @returns The session recording data for this trial, or null if no session
* recording is active.
*/
export const get_session_recording_data = (): ChsRecordingData | null => {
const sessionRecorder = window.chs.sessionRecorder as
| ChsSessionRecorder
| null
| undefined;
return sessionRecorder
? sessionRecorder.getSessionTrialRecordingData()
: null;
};
/**
* Attach captured session recording data to a trial's data, unless the trial
* already has its own recording data (e.g. a start-record or trial-record
* trial, whose block takes precedence).
*
* @param data - The trial's jsPsych data (mutated in place).
* @param recordingData - Session recording data captured at the trial's start,
* or null if there was none.
*/
export const add_session_recording_data = (
data: JsPsychExpData,
recordingData: ChsRecordingData | null,
): void => {
if (recordingData && !data.chs_recording) {
data.chs_recording = recordingData;
}
};
/**
* Retry an async function with exponential backoff. Used for API calls we
* cannot afford to silently give up on (e.g. persisting the final response
* data), where we'd rather make the participant wait than lose data.
*
* @param fn - Function returning the promise to retry on failure.
* @param options - Retry configuration.
* @param options.retries - Max number of retry attempts after the first try.
* @param options.baseDelayMs - Delay before the first retry; doubles each
* subsequent attempt.
* @returns Resolved value of fn(), once it succeeds.
*/
const retryWithBackoff = async <T>(
fn: () => Promise<T>,
{
retries = 3,
baseDelayMs = 1000,
}: { retries?: number; baseDelayMs?: number } = {},
): Promise<T> => {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= retries) {
throw err;
}
await new Promise((resolve) =>
setTimeout(resolve, baseDelayMs * 2 ** attempt),
);
}
}
};
/**
* Function that returns a function to be used in place of jsPsych's option
* "on_data_update". "userFunc" should be the user's implementation of
* "on_data_update". Since this is the data that is returned from each trial,
* this function will get the collected trial data and append the current data
* point.
*
* @param jsPsychInstance - JsPsych instance
* @param responseUuid - Response UUID.
* @param userFunc - "on data update" function provided by researcher.
* @returns On data update function.
*/
export const on_data_update = (
jsPsychInstance: JsPsych | undefined | null,
responseUuid: string,
userFunc?: UserFuncOnDataUpdate,
) => {
return async function (data: JsPsychExpData) {
if (!jsPsychInstance || !jsPsychInstance.data) {
throw new NoJsPsychInstanceError();
}
await Api.updateResponse(responseUuid, {
exp_data: jsPsychInstance.data.get().values() as JsPsychExpData[],
});
await Api.finish();
// Don't call the function if not defined by user.
if (typeof userFunc === "function") {
userFunc(data);
}
};
};
/**
* Function that returns a function to be used in place of jsPsych's option
* "on_finish". "userFunc" should be the user's implementation of "on_finish".
* Since this is point where the experiment has ended, the function will set
* "completed" to true and overwrites all experiment data with the full set of
* collected data. Once the user function has been ran, this will redirect to
* the study's exit url.
*
* @param jsPsychInstance - JsPsych instance
* @param responseUuid - Response UUID.
* @param userFunc - "on finish" function provided by the researcher.
* @returns On finish function.
*/
export const on_finish = (
jsPsychInstance: JsPsych | undefined | null,
responseUuid: string,
userFunc?: UserFuncOnFinish,
) => {
return async function (data: DataCollection) {
// add loading animation while data/video saving finishes
if (!jsPsychInstance || !jsPsychInstance.getDisplayElement) {
throw new NoJsPsychInstanceError();
}
jsPsychInstance.getDisplayElement().innerHTML =
chsTemplates.loadingAnimation();
const exp_data: JsPsychExpData[] = data.values();
const { exit_url } = window.chs.study.attributes;
// Don't call the function if not defined by user.
if (typeof userFunc === "function") {
userFunc(data);
}
// Persist the final response data (including completed: true). This is
// retried with backoff rather than given up on after one failure. Note this
// always sends the *complete* exp_data array, so a successful retry here
// also recovers from any earlier trial's on_data_update having failed.
//
// Known quirk: exp_data and completed: true are written together in a
// single final request. If this update fails after all retries, it's possible
// for the full data set to have been saved by an earlier (un-awaited)
// on_data_update even though the response is not marked completed: true. We
// accept this because having the full data matters more than the completed flag,
// and researchers can manually override the tallied status. Decided NOT to
// fall back to a {completed:true}-only update here because prior
// on_data_update saves aren't awaited and so can't be assumed successful.
try {
await retryWithBackoff(() =>
Api.updateResponse(responseUuid, {
exp_data,
completed: true,
}),
);
await Api.finish();
} catch (err) {
console.error(
"Error while saving final response data after retries: ",
err,
);
}
// Wait for pending recording uploads independently of the response data
// update, so a failure/retry exhaustion in one doesn't prevent us from
// waiting on (and finding out about) the other. (Note that pending uploads
// will still be uploading during the response-data-saving wait time,
// this just waits further for any upload requests that are still pending
// after response data saving is complete.)
try {
if (window.chs.pendingUploads) {
const uploads = window.chs.pendingUploads;
const results = await Promise.allSettled(uploads.map((u) => u.promise));
results.forEach((result, i) => {
if (result.status === "rejected") {
console.error(
`Pending upload failed for "${uploads[i].file}": `,
result.reason,
);
}
});
// Record each upload's outcome in the trial data, matched by filename,
// so the saved response shows which recordings uploaded successfully.
// This happens after the response data save above because upload
// outcomes aren't known until the uploads settle.
let annotatedAny = false;
uploads.forEach((upload) => {
// Require chs_recording to exist so trials without recording data
// never match (and so the assignment below is safe) — e.g. a match
// between two undefined filenames.
const trials = exp_data.filter(
(trial) =>
trial.chs_recording !== undefined &&
trial.chs_recording.filename === upload.file,
);
if (trials.length === 0) {
console.warn(
`No trial data found for uploaded recording "${upload.file}"; its upload status was not recorded in the data.`,
);
}
trials.forEach((trial) => {
trial.chs_recording!.upload_status = upload.status;
if (upload.error_message) {
trial.chs_recording!.upload_error = upload.error_message;
}
annotatedAny = true;
});
});
// Persist the upload outcomes in a second write (they weren't known at
// the first save). Supplementary, so a single attempt: if it fails, the
// full data and completed flag from the first save still stand.
if (annotatedAny) {
try {
await Api.updateResponse(responseUuid, {
exp_data,
completed: true,
});
await Api.finish();
} catch (err) {
console.error(
"Error while saving recording upload statuses: ",
err,
);
}
}
}
} catch (err) {
console.error("Error while waiting for pending uploads: ", err);
}
if (exit_url) {
let url: URL;
try {
url = new URL(exit_url);
} catch {
try {
url = new URL(`https://${exit_url}`);
} catch {
url = new URL(window.location.origin);
}
}
const hash_child_id = window.chs.response.attributes.hash_child_id;
if (hash_child_id) url.searchParams.set("child", hash_child_id);
url.searchParams.set("response", window.chs.response.id);
window.location.replace(url.toString());
}
};
};