Skip to content

LeAP-laboratory/experiment-server

Repository files navigation

LeAP Laboratory experiment server

This is a node.js app that coordinates online data collection. See the experiment template for an example of how to integrate a lab.js front-end.

When paired with the right front-end logic (see experiment template), it handles:

  • managing participant sessions
  • preventing repeat takers
  • assigning participants to lists/conditions in a balanced way
  • storing both incremental and complete experimental data and making it available to download in a JSON format.

Setting up an experiment

Static files for each experiment should be placed in a directory with the name of the experiment inside web-root. Replace spaces, parens, etc. with -, so if your experiment is called “Selective adaptation”, you should put the lab.js export in web-root/selective-adaptation/.

Sandbox

Before deploying the final version of your experiment, do a “test run” in the mechanical turk sandbox. Because the server maintains a record of which users have completed the experiment in order to balance the assignment of users to experimental lists, it’s recommended to use a second instance of the server with its own domain for sandbox testing.

Setting experimental lists

To set up the lists (conditions, etc.) for your experiment, you need to make a PUT request to https://experiments.leap-lab.org/name-of-experiment/lists, with the JSON-encoded array of lists in the body, like so:

PUT https://experiments.leap-lab.org/name-of-experiment/lists Content-Type:
application/json

[
  {
    "list_id": 1,
    "condition": "condition-1",
  },
  {
    "list_id": 2,
    "condition": "condition-2",
  }
]

There is, alas, currently no convenient way of doing this. Curl or emacs-restclient (like above) is your friend.

Conditions

The condition field can contain any valid JSON (number, string, array, or object/dict). It is available on the Lab.js front-end as this.parameters.session.condition. You could use it to set basic conditions (via a string/number), block order (via an array of strings/objects), or some more complicated combination of different values in a crossed design (via an object or array of objects).

Assignment count

If you want something other than an equal number of subjects assigned to each list, you can add a count field to each element. The list balancer will assign subjects to the list with the largest difference between the existing (non-abandoned) sessions in that list and the count. The default value of count is zero, which means that the list balancer by default will assign each new subject to the list with the fewest subjects working/completed.

This is useful mainly if you need to re-balance lists after analyzing the data and dropping some subjects (by pre-defined exclusion criteria of course). Or if you have a strange design where you want many more subjects in some conditions than others.

Configuration

Configuration information is pulled from .env file. This is not under version control, but the .env.example file shows the format.

Components

See docker-compose.yml and docker-compose.devel.yml for the components of the app.

nginx webserver

The webserver service is an nginx webserver. This has two functions. First, it serves static files for the experiments from web-root; any request that doesn’t correspond to a file or subdirectory with index.html in web-root gets passed to the node.js app.

Second, it handles the https configuration and sets response headers correctly to ensure a secure connection.

node.js app

This handles participant sessions in the experiments, including assigning subjects to conditions if needed and collecting data.

It also provides an admin interface to retrieve data, manage condition lists, and monitor user sessions.

mongo db

This records information on each participant session, generated data, and experimental lists.

Deploying

The components of the experiment server are coordinated by docker-compose. The general steps are

  1. Install docker and docker-compose, and start the docker daemon (e.g. with $ sudo systemctl start docker).
  2. Clone this repository.
  3. Copy .env.example to .env and edit as needed (probably at least MONGO_USERNAME, MONGO_PASSWORD, MONGO_DB, DOMAIN, and NODE_ENV).
  4. Create and start the necessary containers with docker-compose up -d.

Depending on where you’re deploying (local vs. remote machine) and to what end (development or production), the specific steps are detailed below. Most of the work is handled by swapping in the appropriate docker-compose file.

Local Development

A separate docker-compose config is provided for local development:

$ docker-compose -f docker-compose.devel.yml up

This will create a container for the database if needed, and listen on port 8080. The local app directory is mounted in the countainer (to /home/node/app) and nodemon listens for changes in the source. This differs from the production docker compose config which copies the app source and static assets into the container when it’s built.

Make sure that no node_modules directory is present since it will mask the volume that’s created by docker-compose.

Remote development

Live development of the experiment server itself can be done on a remote machine by combining the production and development docker compose configs:

$ docker-compose -f docker-compose.yml -f docker-compose.devel.yml up -d

This combines the production nginx web server to handle HTTP/S requests with the live-reloading javascript server.

Production/staging

The default docker-compose.yml configuring is set up for remote production and staging (sandbox) use, so for normal use all that’s necessary is

$ docker-compose up -d

For HTTPS support, read on.

SSL/certbot/LetsEncrypt

This is necessary to support HTTPS (which is required for MTurk external HITs).

The certificates necessary for SSL are written into the certbot-etc and certbot-var volumes by certbot. This is accomplished using a separate docker compose file, which goes on top of the main one like so:

$ docker-compose -f docker-compose.yml -f docker-compose.certbot.yml up certbot

On its own, this will (re-)create the necessary services (webserver) and run certbot. This needs to be done every time the certificate needs to be renewed.

Configuration

The DOMAIN environment variable is used to set the domain name for letsencrypt, so make sure the setting in .env matches the actual domain name you need a certificate for.

Second, make sure the email address you want certificate expiration reminders to go to is listed in docker-compose.certbot.yml file.

Initial certificate

Additional steps are needed for initial certificate acquisition.

First, because there’s no certificates in place, you need to (temporarily) adjust the nginx configuration (in nginx-conf/nginx.conf). Right now this is handled awkwardly: you have to manually uncomment the bit in the first server block (to allow access to files over HTTP), and comment out the entire second server block (which will block nginx from starting because of the missing certificates). Then run certbot as before:

$ docker-compose -f docker-compose.yml -f docker-compose.certbot.yml up certbot

Second, once the certificates are in place, the diffie helman parameter needs to be generated, like

$ mkdir dhparam
$ sudo openssl dhparam -out "$PWD/dhparam/dhparam-2048.pem" 2048

Certificate renewal

Every 90 days you must renew the certificates; LetsEncrypt will email you a reminder at the email address in the dockerfile. Renewal is simple matter of running certbot again and re-starting the webserver to load the new certificates (the --no-deps flag keeps docker-compose from recreating all the other containers, which isn’t necessary when the webserver is already running)

$ docker-compose -f docker-compose.yml -f docker-compose.certbot.yml up --no-deps certbot

Then the new certificates need to be loaded into nginx. You can either re-start the whole container using up --force-recreate --no-deps webserver or (slighly more gracefully) send a SIGHUP signal to the nginx process with

$ docker-compose exec webserver nginx -s reload

Which will validate and load the new certificates and restart any worker processes as necessary.

Interaction

experimental lists: /:experiment/lists/

The lists of conditions and number of assignments to put in each condition is read from the lists database, which stores documents like this:

[
  {
    "list_id": 1,
    "experiment": "a-nice-experiment",
    "condition": "good-condition",
    "count": 10
  },
  {
    "list_id": 2,
    "experiment": "a-nice-experiment",
    "condition": "okay-condition",
    "count": 5
  }
]

Note that when updating lists, the experiment is added automatically based on the URL, and in fact any values specified directly in the JSON will be ignored.

count gives the desired number of assignments for this list. Anything stored under condition will be stored on the session returned to the client.

Update lists and/or target assignment counts with PUT

PUT http://localhost:8080/a-nice-experiment/lists
Content-Type: application/json

[
{
"list_id": 1,
"condition": "nothign",
"count": 11
},
{
"list_id": 1,
"condition": "nothing",
"count": 11
},
{
"list_id": 2,
"condition": "something",
"count": 10
}
]

GET lists for experiment

GET http://localhost:8080/a-nice-experiment/lists

GET lists with additional filter

GET http://localhost:8080/a-nice-experiment/lists?condition=nothign

DELETE lists

Only exposed in development mode (when NODE_ENV != "production").

DELETE http://localhost:8080/a-nice-experiment/lists?condition=nothign

sessions: /:experiment/session/

Open new session

We use PUSH to request a new session. If a matching session is not found in the database, a new session is created. The criterion for matching is having the same workerId and experiment.

The body of the PUSH request has the metadata about the session to store (workerId is mandatory, others are optional).

POST http://localhost:8080/a-nice-experiment/session
Content-Type: application/json

{
"assignmendId": 1233445,
"workerId": "dave",
"hello": "world"
}

The session_id is needed for future requests (to get information on a specific session and to update the status of a session)

During preview, no workerId is assigned, but assignmentId is set to ASSIGNMENT_ID_NOT_AVAILABLE. In this case, no record is created and condition is set to preview:

POST http://localhost:8080/a-nice-experiment/session
Content-Type: application/json

{
"assignmentId": "ASSIGNMENT_ID_NOT_AVAILABLE"
}

POST updates to session status

This is used by the client to update the server on progress of the experiment, or in case the session is abandoned by closing the window. The body of the request is set as the new status (parsed as plain text).

POST http://localhost:8080/a-nice-experiment/session/680c34d8-a2b4-4f53-be82-fb395a9ef884/status
Content-Type: text/plain

okay

GET a listing of all sessions for an experiment

GET http://localhost:8080/a-nice-experiment/session/

GET information on an existing session

(This uses the ID returned in the POST call above)

GET http://localhost:8080/a-nice-experiment/session/680c34d8-a2b4-4f53-be82-fb395a9ef884/

data: /:experiment/data

POST recorded data

The client should send recorded data to the serer using a POST request to the experiments data endpoint:

POST 

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published