Skip to content

Quick start

You need Docker with Compose. Nothing else: no Node, no database, no account.

Terminal window
git clone https://gitlab.com/jojithedev/wayscribe.git && cd wayscribe
Terminal window
docker compose -f infrastructure/compose.yaml \
-f infrastructure/compose.demo.yaml up --build

That builds the images and boots the API, the interface, PostgreSQL, a queue, and four demo services that bring their own broken integration to investigate. Measured on 2026-09-14 from a fresh clone on a laptop with no Docker layer cache, the build took 38 seconds and the boot 12; the first run also downloads the base images.

Then start a journey:

Terminal window
curl -X POST http://localhost:3100/trigger

Four demo services move a Salesforce account through a webhook, a transformation, PostgreSQL, a queue, a worker, and a third-party API. The transformation contains a real defect, the queue really retries, and the target really rejects the result with a 422. About ten seconds after the trigger the journey has reached its dead-letter state: the demo queue redelivers after 3 seconds and dead-letters on the third receive (elasticmq.conf).

Open http://localhost:3000 and sign in with the admin token, which is replace-for-local-development-0000 until you set your own (below). Search 0018Z00002ABC. That is the journey above. (pnpm demo:trigger does the same as the curl and prints the direct link, if you have Node 24 and pnpm.)

The API binds 127.0.0.1:8080 and the interface 127.0.0.1:3000. If either port is taken on your machine, move it:

Terminal window
API_PORT=8081 WEB_PORT=3001 docker compose -f infrastructure/compose.yaml \
-f infrastructure/compose.demo.yaml up --build

Set your own ADMIN_TOKEN and ENCRYPTION_KEY before this holds anything real:

Terminal window
cp .env.example .env && printf 'ENCRYPTION_KEY=%s\nADMIN_TOKEN=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" >> .env

Recreate the stack with the same up command for them to take effect. What the demo already recorded stays under the default key, which the new one cannot read; start from an empty database with down -v first if that matters. The API logs a warning at every boot while the published development defaults are still in use.

Each row says what is supported and what CI actually runs, which are not always the same thing. tests/supported-versions.test.ts fails when this table disagrees with .gitlab-ci.yml or with the engines fields.

Component Supported What CI tests
Node.js for the SDK and the CLI 22.12 or later 22.12.0 and 24: the SDK’s build and unit tests, the CLI’s unit tests, and import and require() of the packed SDK tarball in fresh projects (sdk-node)
Node.js for running from a clone 24 24: every other job runs on node:24 or node:24-alpine
PostgreSQL 15 or later 15, 17 and 18: the whole integration suite, migrations included (database). 16 is not run; it lies between two releases that are
Docker Compose 2.24 or later not pinned: the manual demo job and the release gate use Alpine’s current docker-cli-compose. 2.24 is the oldest Compose that reads env_file with required: false, which the Compose files use
Container images linux/amd64, linux/arm64 the runner’s own architecture only: container-scan builds and scans both images on default-branch pipelines. Releases build both platforms; the arm64 images are not run in CI

doctor fails on PostgreSQL older than 15 and warns on a release newer than the newest one CI tests.

For a common stack, start from a recipe.

The SDK is not published to npm yet. Until it is, pack it from a clone of this repository, commit the tarball to your application, and depend on it by path:

Terminal window
pnpm install
pnpm --silent --filter @wayscribe/node run pack:release /path/to/your-app/vendor/
cd /path/to/your-app
npm install ./vendor/wayscribe-node-0.1.0.tgz # records "file:vendor/…tgz"

A tarball is a built copy that travels with your application. A path into the clone instead links to a directory whose build output is not in git, which breaks on the next git clean, branch switch, or machine without the clone, and nothing rebuilds it for a job that has no build step of its own. After pulling a change that adds or updates the tarball, run npm ci before the job runs again; a deploy that skipped it hung. The SDK README has the details. Once the package is published, all of this becomes npm install @wayscribe/node.

import { createRecorder } from "@wayscribe/node";
const recorder = createRecorder({
endpoint: "http://localhost:8080",
apiKey: process.env.WAYSCRIBE_API_KEY,
serviceName: "billing-api",
environment: "development",
// Prints `delivered_first` once events are stored, or why they are not.
// Turn it off once the service is known to send.
logDiagnostics: true
});
// A journey is one record's history. The entity is what you will search for.
const journey = recorder.startJourney({
entity: { type: "customer", id: account.Id }
});
// Each wrapper runs your code, returns its value unchanged, and records what
// went in and what came out. The difference between those two is the point.
const customer = await journey.transform("map-account", account, () =>
toCustomer(account)
);
const id = await journey.persist("save-customer", customer, () =>
db.customers.insert(customer)
);
// Aliases are the other identifiers this record answers to. Now a colleague who
// only has the internal ID can still find this journey.
journey.identify({ internalCustomerId: String(id) });

Then search for account.Id. Or the internal ID. Or any other identifier you attached.

examples/instrument-a-service is a standalone project that does this end to end, including the part where a value goes missing; the instrumentation in it is about thirty lines.

An observability library that takes down the service it observes is worse than no library. This one is built so that cannot happen:

  • No runtime dependencies. It brings nothing with it.
  • Every entry point is wrapped. A recorder failure increments a counter and returns; it never reaches your code.
  • Wrappers rethrow your exact error object, so instanceof checks and custom properties on your errors keep working.
  • The event queue is bounded, and drops oldest under backpressure rather than growing without limit.
  • The transport retries behind a circuit breaker and gives up rather than piling up.
  • shutdown() races the final flush against a timeout and never hangs.
  • Nothing is written to your console unless you ask for it, apart from six warnings printed once per process: a required setting that is missing or empty, an optional setting the recorder could not use, a setting under its old name, a journeyIdSecret that cannot be used, a field whose name looks like a secret that was sent in plain text, and a journey label or displayable alias that looks like personal data (SDK README).