@lightmill/log-client
v5.0.1
Published
Client for LightMill's log server.
Readme
@lightmill/log-client
Send an experiment's logs from the browser to a LightMill log server.
The client starts and resumes runs, and gives you a logger for each. The logger numbers your logs, sends them in batches, retries when the network fails, and keeps unsaved logs for retry while the page stays open. It works with any interface; @lightmill/react-experiment connects it to React.
Install
npm install @lightmill/log-clientIt supports Chrome and Edge 85, Firefox 90, and Safari 15 (macOS and iOS) or later.
Example
import { Client } from '@lightmill/log-client';
type Log =
| { type: 'trial-start'; trialId: string }
| { type: 'trial-end'; trialId: string; durationMs: number };
const client = new Client<Log>({ apiRoot: 'https://study.example.org/api' });
const logger = await client.startRun({
experimentName: 'pointing-study',
runName: 'participant-42',
});
await logger.addLog({ type: 'trial-start', trialId: '1' });
await logger.addLog({ type: 'trial-end', trialId: '1', durationMs: 812 });
await logger.completeRun();The experiment must exist on the server: the client never creates one. See creating an experiment.
The session that identifies the participant is a cookie, so the server must accept the page's origin, and the cookie must be allowed by the browser. Deploying explains which server options to use.
Runs
A run is one participant going through the experiment once. Start a new one with startRun({ experimentName, runName }). The run name identifies the participant within the experiment: a run name already used by a run that isn't canceled makes startRun reject, with an error whose code is RUN_EXISTS. The name is optional; without it, only getResumableRuns can find the run again.
Resume a run with startRun({ runId, after }) or startRun({ experimentName, runName, after }). after is { number }, the log number to resume after: the server cancels every later log, and the new logs continue from there. getResumableRuns finds the runs to resume and the log to resume after. Resuming runs shows the whole process.
Complete, interrupt, or cancel a run with one of three calls:
completeRun()when the participant finished.interruptRun()when the participant stops for now and may come back. An interrupted run can be resumed.cancelRun()when the run should not count. Its name becomes free again, and the CSV export leaves it out.
Each first sends the logs not sent yet, and rejects if it can't. After any of these calls succeeds, this logger accepts no more logs. Completed and canceled runs have ended; interrupted runs can still resume through client.startRun, which returns a new logger.
Logs
A log is an object with a type and any other values:
logger.addLog({ type: 'trial-end', trialId: '1', durationMs: 812 });Every log gets a
date, the timeaddLogwas called, unless it has one already.Values can be JSON values or
Dates, nested in objects and arrays. Dates are sent as ISO 8601 strings, andundefinedproperties are left out.For other values, such as a
bigintor aMap, give the client aserializeLogfunction that turns a log's values, without itstype, into JSON. TypeScript requires it when your log type has such values.type Log = { type: 'count'; total: bigint }; const client = new Client<Log>({ apiRoot: 'https://study.example.org/api', serializeLog: ({ date, total }) => ({ date: date.toISOString(), total: total.toString(), }), });The logger numbers logs in the order you add them, starting after the last log of the run.
Delivery
addLog queues the log and returns a promise that resolves once the server has stored it. You don't have to wait for it, but catch its rejection: the logger keeps the log either way, and completeRun() won't complete the run until it is stored. The logger sends one batch at a time: logs added while a batch is on its way go in the next one, up to about 512 kB per batch. requestThrottle sets a minimum time between the starts of two batches.
flush() sends the queued logs at once, and resolves when every log added before the call is stored. If logs were in flight when it was called, it then checks for missing log numbers at or before the last log it flushed, and rejects if one is missing. With no logs in flight, it returns immediately without checking the server.
When the network fails
The logger sends a batch again after a network error, a timeout, a 5xx, a 408, or a 429 (waiting as long as Retry-After asks), for up to two minutes. A request times out after requestTimeout.base milliseconds (10,000 by default), plus requestTimeout.perKilobyte milliseconds per kilobyte sent (100 by default). A batch the server refuses as too large (413) is sent again in halves.
When retries run out, or the server refuses a batch for another reason, the logger pauses. While the page stays open, it keeps unsaved logs until you retry or explicitly discard them:
- the
addLogpromises of the failed batch reject; - every other log not stored yet is held, and its
addLogpromise stays pending; logger.inFlightLogslists the logs not stored yet, for example to let the participant download them;logger.retry()sends them again, with a fresh two minutes, and resolves once they are stored;flush()andcompleteRun()reject while logs are held, and so docancelRun()andinterruptRun(), unless they get{ discardInFlightLogs: true }. Discarding aborts a batch on its way, but the server may have stored it already.
Unsaved logs
The logger keeps the logs that haven't reached the server in the page's memory only. Reloading or closing the page loses them, and nothing stores them in the browser. A participant who resumes starts after the last log the server has, and repeats the tasks after it.
So, while the run is ongoing, ask the browser to confirm before the page closes or reloads, with useConfirmBeforeUnload from @lightmill/react-experiment or a beforeunload listener. It saves nothing, and browsers don't always show it. See Avoid losing progress.
When saving fails, also ask participants to keep the page open while they retry, and offer a download of inFlightLogs before they leave. The getting started app does all three.
State
logger.state says how delivery is going:
| status | Meaning |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| idle | Every log is stored. |
| sending | A batch is on its way. |
| retrying | The last attempt failed and will be retried. Has error, attempt, and delayMs. |
| paused | Retries ran out. Logs are held until retry(). Has error. |
| completed, canceled, interrupted | This logger has stopped. An interrupted run can resume with a new logger. |
Only log batches count: retries while flush() checks for gaps, or while a call ends the run, only show in that call's promise.
logger.subscribe(listener) calls listener with the new state on each change, and returns a function that stops it. state stays the same object until it changes, so both work with React's useSyncExternalStore. Without React:
logger.subscribe((state) => {
retryButton.hidden = state.status !== 'paused';
});
retryButton.onclick = () => logger.retry().catch(() => {});@lightmill/react-experiment shows how to stop a timeline while the logger is paused.
API
new Client<Log>(options)
| Option | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apiRoot | Base URL where the log API is served. Routes such as /sessions are appended to it: https://study.example.org/api calls https://study.example.org/api/sessions. Include a path prefix only when your server uses one. |
| serializeLog | Turns a log's values into JSON. See Logs. |
| requestThrottle | Minimum time in milliseconds between the starts of two batches. Defaults to 0. flush() ignores it. |
| requestTimeout | { base, perKilobyte }, in milliseconds. Defaults to { base: 10000, perKilobyte: 100 }. |
Log is the union of your log types.
client.startRun(options)
Starts or resumes a run, and resolves with its logger. options is one of:
{ experimentName, runName? }to start a new run;{ experimentName, runName, after }to resume a run by name;{ runId, after }to resume a run by id.
client.getResumableRuns({ resumableLogTypes, experimentName?, runName? })
Lists the runs the current browser session started that are running or interrupted, optionally filtered by experiment and run name. Each result is { run, experiment, toResumeAfter }:
runis{ id, name, status },experimentis{ id, name };toResumeAfteris the run's last log whose type is inresumableLogTypes, as{ number, log }, or{ number: 0, log: null }when there is none. Stranded logs, which come after a missing log number, don't count. Pass it asaftertostartRun.
Without a session, it resolves with an empty list.
client.logout()
Ends the session on the server. The browser can no longer find its runs.
Logger
| Member | Description |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| addLog(log) | Queues a log. Resolves once it is stored. Rejects while this logger is stopping or after it has stopped. |
| flush() | Sends queued logs at once. Resolves once every log added before is stored. |
| retry() | Sends held logs again. Does nothing unless paused. |
| completeRun() | Flushes, then completes the run. |
| interruptRun({ discardInFlightLogs? }) | Flushes, or discards, then interrupts the run. |
| cancelRun({ discardInFlightLogs? }) | Flushes, or discards, then cancels the run. |
| state | Delivery state. See State. |
| subscribe(listener) | Calls listener on each state change. Returns an unsubscribe function. |
| inFlightLogs | Logs not stored yet, whether queued, on their way, or held. |
Only one call can complete, interrupt, or cancel the run at a time: while one is in progress, another rejects, and so does addLog.
Types
Logger, LoggerState, RequestTimeout, and LogValuesSerializer are exported for TypeScript, along with the error classes.
Errors
The package exports its error classes, so you can check failures with instanceof:
RequestError: the server refused a request. It has the HTTPstatus,statusText, andheaders, and the server'serrors.codeanddetailcome from the first error, such asRUN_EXISTS, and may beundefined.AddLogError: a log could not be stored, or was discarded.logNumberis its number, andcausethe delivery error, if any. Errors thrown before the log is queued, such as byserializeLog, pass through unchanged.FlushError:flush()found a missing log number once its logs were sent. The message names the number and how to recover.completeRun(),interruptRun(), andcancelRun()can reject with it too, since they flush first.
startRun rejects with a plain Error when the experiment does not exist.
import { RequestError } from '@lightmill/log-client';
try {
await client.startRun({ experimentName: 'pointing-study', runName });
} catch (error) {
if (error instanceof RequestError && error.code === 'RUN_EXISTS') {
showMessage('This participant has already taken part.');
} else {
throw error;
}
}