npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-client

It 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 time addLog was 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, and undefined properties are left out.

  • For other values, such as a bigint or a Map, give the client a serializeLog function that turns a log's values, without its type, 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 addLog promises of the failed batch reject;
  • every other log not stored yet is held, and its addLog promise stays pending;
  • logger.inFlightLogs lists 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() and completeRun() reject while logs are held, and so do cancelRun() and interruptRun(), 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 }:

  • run is { id, name, status }, experiment is { id, name };
  • toResumeAfter is the run's last log whose type is in resumableLogTypes, 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 as after to startRun.

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 HTTP status, statusText, and headers, and the server's errors. code and detail come from the first error, such as RUN_EXISTS, and may be undefined.
  • AddLogError: a log could not be stored, or was discarded. logNumber is its number, and cause the delivery error, if any. Errors thrown before the log is queued, such as by serializeLog, 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(), and cancelRun() 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;
  }
}