Changelog
Notable changes to Wayscribe, which was called Flight Recorder until 2026-09-17. Dates are the day the work merged.
Versions follow semantic versioning. Before 1.0 the minor version may carry breaking changes; the patch version will not.
The published artifacts are versioned together: the api and web images and
the @wayscribe/node package share a version, because the event protocol
is the contract between them and a mismatch is not something a user should have
to reason about. The protocol itself carries its own protocolVersion, which
changes far less often.
[Unreleased]
Section titled “[Unreleased]”This section describes the first release, a 0.x preview. Nothing has been
published before it, so there is no earlier release to upgrade from. Under
Changed and Upgrade notes, “changed” means changed from earlier
development builds of main, for anyone running one from a git checkout. A
shorter overview is in
docs/RELEASE_NOTES_DRAFT.md.
Renamed to Wayscribe (2026-09-17)
Section titled “Renamed to Wayscribe (2026-09-17)”The product was called Flight Recorder until this change (ADR-057). The entries below this one were written before it and use the old names; this table gives the new one for each. There are no aliases: the old names are not read.
| What | Before | Now |
|---|---|---|
| Product | Flight Recorder | Wayscribe |
| Packages | @flight-recorder/*, SDK @flight-recorder/node |
@wayscribe/*, SDK @wayscribe/node |
| CLI binary | flight-recorder |
wayscribe |
| HTTP headers | x-flight-journey-id, x-flight-entity-type, x-flight-entity-id, x-flight-replay, x-flight-project-id, x-flight-api-key |
x-wayscribe-journey-id, x-wayscribe-entity-type, x-wayscribe-entity-id, x-wayscribe-replay, x-wayscribe-project-id, x-wayscribe-api-key |
| Queue message attributes | flightJourneyId, flightEntityType, flightEntityId |
wayscribeJourneyId, wayscribeEntityType, wayscribeEntityId |
| Payload envelope key | _flight |
_wayscribe |
| Environment variables | FLIGHT_RECORDER_API_KEY, _URL, _TOKEN, _PROJECT, _ENVIRONMENT, _VERSION, _WEB |
WAYSCRIBE_API_KEY, _URL, _TOKEN, _PROJECT, _ENVIRONMENT, _VERSION, _WEB |
| Demo, acceptance and browser suite variables | FLIGHT_API_KEY, FLIGHT_API_URL, FLIGHT_ENDPOINT, FLIGHT_ENVIRONMENT |
WAYSCRIBE_API_KEY, WAYSCRIBE_API_URL, WAYSCRIBE_ENDPOINT, WAYSCRIBE_ENVIRONMENT |
| Prometheus metrics (nine families) | flight_recorder_* |
wayscribe_* |
| Alert rules | FlightRecorderRetentionStalled, FlightRecorderRejectingEvents, FlightRecorderDatabaseStrained |
WayscribeRetentionStalled, WayscribeRejectingEvents, WayscribeDatabaseStrained |
| SDK print prefix | [flight-recorder] |
[wayscribe] |
| Web session cookie | flight_session |
wayscribe_session |
| New API keys | fr_ and 32 characters |
wsk_ and 32 characters |
| Published demo key | fr_demo... |
wsk_demo... |
| Images | registry.gitlab.com/jojithedev/flight-recorder/{api,web}, demo flight-recorder-demo:local |
registry.gitlab.com/jojithedev/wayscribe/{api,web}, demo wayscribe-demo:local |
| Helm chart | deploy/helm/flight-recorder, example release fr |
deploy/helm/wayscribe, example release ws |
| Compose project and network | flight-recorder, flight-recorder_default |
wayscribe, wayscribe_default |
| Local database user, password and name | flight |
wayscribe |
| GitLab project (moved 2026-09-17) | jojithedev/flight-recorder |
jojithedev/wayscribe |
What keeps working:
- Stored data. The key-derivation labels did not change, so encrypted identifiers and search tokens from before the rename still work, and no migration was added.
- API keys that start
fr_. The server never checked the prefix. Masking in error text anddoctoraccept both forms, anddoctorstill warns while the old published demo key is active.
What you have to do when upgrading a checkout or a deployment:
- Rename what your services set: the SDK dependency and imports, the environment variables, and any header or queue attribute a service other than the SDK reads or writes.
- Update dashboards and alert rules to the
wayscribe_*metrics and the new alert names. - Sign in to the interface once more. The session cookie is now
wayscribe_session. A session is still signed with the same key, but the browser holds it under the old name, which is no longer read, so everyone signed in is asked to sign in again, once. - Pull images from the new path. The old registry path does not redirect.
- Start a local Compose stack afresh. The project, volume and database
user changed, so an old stack’s database is not picked up
(LOCAL_DEVELOPMENT.md).
Copy
.env.exampleto.envagain, or change the user and name inDATABASE_URL.
Recording and the Node SDK
Section titled “Recording and the Node SDK”@flight-recorder/node, the Node SDK, for Node 22.12 or later. No runtime dependencies; ESM, with one bundled file and one declaration file. A Stability section in the SDK README lists what is experimental.- Wrappers that record without changing what they wrap.
transform,persist,publishanddeliverreturn the callback’s value unchanged and rethrow its exact error. A synchronous callback returns and throws synchronously, and the types carry overloads that say so.WrapOptionsis generic in the callback’s result, socaptureOutputandisFailuresee the resolved value with its type.operationis a typed union, exported asOperation. - Projections.
captureInputandcaptureOutputchoose what is recorded while the wrapper still returns the callback’s own value, so a step that returns a PDF can record{ bytes: buffer.length }.captureInputrecords the input as it was when the projection ran. A projection that throws or returns a promise records[UNCAPTURABLE]and apayload_omitteddiagnostic with codeprojection_failed. - The SDK cannot break the application it records. Every entry point is
inside the failure boundary, including the propagation helpers, and none
throws over what it is given: missing options, throwing getters, Proxies or
malformed values degrade to a new journey, a first attempt, or the setting’s
default, with a
capture_errororconfiguration_error. A wrapper reads each option once. A callback returning any thenable gets a native promise back. - Every setting is checked at creation (SDK-60). A timer, queue bound or
byte budget that is not a whole number in range, a clamped
batchSizeormaxConcurrentSends, an unknowncaptureModeorpropagation, a non-booleanlogDiagnostics, and aredactthat is not a list of strings are each reported asconfiguration_errorand replaced by the default or clamped. A missing or non-stringendpoint,apiKey,serviceNameorenvironmentprints one line per process even withlogDiagnosticsoff, naming the setting and never its value. Every option type is named and exported, and every optional input accepts an explicitundefined. - Payloads are copied faithfully and safely. A
Dateand anything with atoJSONserialise themselves;Map,Set,Error,RegExp,HeadersandURLSearchParamskeep their contents and are rendered inside the redaction walk (ADR-036); a cycle becomes[CIRCULAR], a shared reference is not mistaken for one, and aBigIntbecomes its decimal string (ADR-034). A__proto__key is kept as an ordinary key. NUL bytes and lone surrogates are repaired before an event leaves the process. A value that cannot be serialised reportspayload_omittedwith codeunserialisable. - Every event is fitted to the server’s limits before it is sent
(ADR-051), by the same check ingestion runs,
eventLimitsfrompayload-security. A string over 65,536 characters is cut to its start and[TRUNCATED: <n> characters removed]; a payload that still does not fit, or is too deep or too wide, becomes[PAYLOAD_TOO_LARGE], the larger ofinputandoutputfirst andmetadatalast; and the event is always sent.maxEventBytesis the budget of one whole event and should match the server’sMAX_EVENT_PAYLOAD_BYTES. A metadata key, alias or error field the server would refuse is left off or cut and reported askey_dropped, and the event is sent.payload_truncatedandpayload_omittedreport cut and replaced payloads, naming thefield. - Delivery that reports what the server stored. The SDK reads the batch
route’s per-event verdicts. A refusal below 500 is permanent and counted as
rejectedwith the server’s error indetail.serverError; a poisoned event is refused alone rather than failing its batch. A per-event refusal of 500 or above (storage_error,query_timeout) is retried with backoff for up to 30 seconds from its first refusal or 10 sends, then counted asdropped. A whole request that fails is retried behind a circuit breaker for as long as the outage lasts, bounded bymaxBufferedEvents, whose overflow drops the oldest events. A 2xx without a usable verdict counts the events it does not cover asdroppedwith codeno_verdictand does not resend them. - Bounded sending and shutdown.
maxConcurrentSends, default 4 and clamped to 1-16, caps concurrent batches, including those a publicflush()starts.flush()andshutdown()are awaited;shutdown()races the final drain against its timeout, aborts a batch still in flight when the timeout wins, counts everything it could not deliver asdropped, and does not hold the process open once the drain finishes. The SDK README’s “SizingmaxConcurrentSends” says when to raise it. - Counters add up.
counters()returnsrecorded,sent,rejected,droppedand the per-kind counts, andsent + rejected + dropped === recordedaftershutdown(). - Diagnostics, when asked. Each diagnostic has a
kind, a stablecodeto match on, areasonfor people, and adetail.logDiagnostics: truewrites each toconsole.erroras one[flight-recorder]line, at most one per kind per minute with a count of suppressed repeats, carrying a masked, bounded reason and never a payload or a key.delivered_firstreports the first batch the server stored anything from, naming the endpoint’s scheme, host and port only.insecure_endpointreports anhttp:endpoint on a dotted name or an IP address once, at creation;localhost, loopback addresses,.localhostnames and single-label names are not reported. Off by default: nothing reaches the console unless it is set, apart from the one-line warnings described in this section. - Journeys that can be found by more than their id.
journey.identify(aliases, { displayableAliases })adds identifiers the record answers to; aliases listed as displayable are shown in full (ADR-053).journey.label(text), orlabelinstartJourney, sets a public label the Journeys page shows and matches by partial text; a label over 200 code points is cut and reported, and an empty or non-string one is not set and is reported askey_dropped(SDK-58, SDK-59). - A stable journey id per record.
recorder.journeyIdFor(entity)derives the id under ajourneyIdSecretof at least 32 bytes (ADR-052, SDK-55), so the same record lands in the same journey on every run and machine while the id cannot be guessed from the entity. It never throws: without a usable secret it reports aconfiguration_errorand prints one warning line per process, and for an entity it cannot encode faithfully it reports why; either way it returns a random id. Test vectors are inpackages/protocol/fixtures/journey-id-derivation.json. - One operation on many journeys.
recorder.across(journeys)gives a group withrecord, the four wrappers,failandfinish: each journey gets its own event, and the callback runs once. - Cross-process propagation over HTTP headers (
injectHttpHeaders,extractHttpContext, which also reads a fetchHeaders), SQS message attributes (injectSqsAttributes,extractSqsContext) and a payload envelope (injectPayload,extractPayload), with three levels. The entity id does not propagate by default, and aliases never do.recorder.continueJourney({ journeyId, entity })resumes a journey from a context; an invalid journey id or entity is reported (journey_id_invalid,entity_invalid) and not used. - OpenTelemetry interoperability. If OpenTelemetry is installed, the SDK
reads the active trace and span ids onto each event. It does not write
traceparent. - What the SDK costs is measured. The package’s
benchscript reports added latency per wrapped call, heap and event-loop delay under sustained load, and throughput by send concurrency, andbench:fleetmodels many processes against one API instance.bench -- --awakerepeats the latency run with a core kept awake, andbench/capture-cpu.mjsreports the processor time per call without pacing. The SDK README’s “What it costs” has the numbers and the machine they came from.src/capture-walks.test.tscounts how often capture checks, redacts and stores a payload, andsrc/overhead.test.tstrips on a gross slowdown of a wrapped call.
The server and the contract
Section titled “The server and the contract”- Ingestion.
POST /v1/eventsandPOST /v1/events/batch, authenticated by an API key scoped to one project and environment, with server-side redaction, structural payload diffs computed at ingestion (ADR-024), and idempotent duplicate handling; a conflicting duplicate is409 event_id_conflict(ADR-021). An event whose journey belongs to another environment is refused (see Security). A value PostgreSQL cannot store is400 unstorable_payload, and a__proto__key anywhere in a body is stored as an ordinary key. - Dry-run validation (ADR-050).
POST /v1/events/batch?dryRun=trueruns the whole batch and rolls it back, answering200withdata.dryRun: true, the per-event results a real send would give, and, for an accepted event, the event and journey as the read routes would return them. Nothing is written. The parameter is strictlytrueorfalse, given once, andPOST /v1/eventsrefuses it, so a client on the wrong route cannot store events while believing it validated them. - Journey labels and last steps (ADR-054). Events carry an optional
journeyLabel, 1 to 200 code points. A journey keeps the label, and thenameof its last step, from the event with the latesttimestamp, whatever order events arrive in, with ties broken as the timeline breaks them. The label is not redacted. - Displayable aliases (ADR-053). Every alias is masked when read, except
one whose type every event that stated it listed in
displayableAliases; a later statement can mask it and nothing can unmask it. A displayable alias value also has a plain-text copy for matching, which the database refuses to keep for a masked alias (entity_aliases_display_value_only_when_displayable) and clears whatever writes the row (entity_aliases_clear_masked_display_value). - Errors in this API’s own vocabulary. Refusals before a route runs are
413 payload_too_large,415 unsupported_media_typeand400 malformed_json. No response carries a PostgreSQL SQLSTATE: a malformed id in a path is 404, a malformed body field or query value 400, and any other unexpected failure500 internal_error.durationMsabove 2147483647 is refused asinvalid_event. - A statement timeout.
DATABASE_STATEMENT_TIMEOUT_MS, 15000 by default, cancels any statement the API runs past it and answers503 query_timeout; the log names the route and never the query.0disables it. Deletions lift it for their own bounded transactions. docs/INGESTION_CONTRACT.md, normative for the two ingestion routes and written for somebody building a client that is not this repository’s Node SDK: routes and authentication, limits with their configuration names and defaults, every refusal with its status and whether to retry it, idempotency and the keyed content hash, the dry run, and the conformance case format. Its tables are asserted against the code (ADR-049).docs/SDK_SPEC.md, what a recorder in any language must do: sixty-two numbered requirements in RFC 2119 wording, each with a source, and each with either the conformance case that checks it or a place in section 14, which lists what no fixture can express.docs/NODE_SDK_SPEC.mdis the Node appendix.- Conformance fixtures, under
packages/protocol/conformance/: forty-fourwirecases and thirty-sevensdkcases that any implementation can run through the dry run, loaded in order of id. Cases can expect diagnostics (expect.diagnostics) and text no diagnostic may contain (expect.absentFromDiagnostics). A fixture change is a contract change (ADR-049). - Generated JSON Schema for the wire shapes, under
packages/protocol/schemas/0.1/, exported as./schemas/*: nine files in draft 2020-12, generated from the Zod schemas and checked byte for byte by a unit test.MAX_JOURNEY_LABEL_LENGTHis also exported from the import-free subpath@flight-recorder/protocol/limits, so the SDK bundle does not carry Zod.
Finding a record
Section titled “Finding a record”- Search. Find a record by any identifier it is known by, then read its
timeline across services. Alias search is independent of alias type (ADR-028).
Each kind of identifier is one index lookup, so a value matching a few
journeys takes about 0.1 ms at a million journeys (
docs/OPERATIONS.mdsection 10). - Event metadata on screen. The event detail lists the step’s custom,
deployment and runtime metadata,
metadata,deploymentandruntimeon the event the SDK sent, as plain keys and values, one group per kind. Before this the API returned all three and the web app showed none of them, so an HTTP status moved from a step’s output into its metadata, as the SDK advises, disappeared from the screen (F-044). Keys and values are shown as escaped text, never as markup; at most 50 entries per kind and 300 characters per key or value are shown, and the page says how many entries it left out. - An event shows the same payload keys however it is reached. On an
event’s first load, with or without JavaScript, a payload or diff value key
named
__proto__, at any depth, was dropped, so a stored{"__proto__":1}showed as{}, while choosing the same step on the timeline showed every key. The web app now writes payloads, the error, diff values and metadata as text on the server, in one function every page and the timeline read an event through, so both ways show the same text. The text is unchanged: payloads and errors pretty-printed, diff values compact, and a missing side of a change shown as a dash. - The Journeys page (ADR-054).
/journeyslists what happened in a period, any status and the last 24 hours by default, as a table of last activity, status, entity type, what the journey is shown as, last step and events. It filters by an hour, a day, a week, 30 days or a custom range, and by status, entity type, environment, service and a Contains box; a Failures shortcut shows only what failed; and a filtered view is a short, shareable link. A journey is shown as its label, else its displayable alias values, else its entity type and identifier. The navigation link reads Journeys. GET /v1/journeysbehind it (docs/API_SPEC.mdsection 6): a requiredsince(the refusal names the format),until,status,environment,service,entityType, andq, 2 to 200 characters, which matches ignoring case part of a journey’s label or of a displayable alias value and nothing else. Rows carrylabel,lastStepanddisplayableAliases, as do rows ofGET /v1/search.GET /v1/projectsnames each project’s environments. Measured at 120,000 journeys indocs/OPERATIONS.mdsection 10, withscripts/measure-journey-list.mjsto reproduce the figures.GET /v1/searchtakes a window and an environment (docs/API_SPEC.mdsection 5):since,untilandenvironment, all optional, beside theq,limitandcursorit already read.sinceanduntilbound a journey’s last activity, the same column and the same half-open rangeGET /v1/journeystakes, so one pair of bounds narrows both endpoints.environmentis a name, applied on top of the caller’s scope and never instead of it (ADR-029): for the admin token it picks one environment of the named project, and for an API key, which already reads its own environment and nothing else, naming that environment changes nothing and naming another returns an empty page rather than an error. With none of the three the search spans the project’s whole history, as it always has, which is what made a repeated alias value return every journey that ever carried it. No index was needed: measured at 200,000 journeys, the bounds filter the plan the search already had, and a window narrow enough to be worth an index is served byjourneys_project_recent_idx.- The search box narrows too. Under the search box, Time (any time, the
last hour, day, week or 30 days, or a custom range in UTC) and Environment
(all, or one of the project’s) send
since,untilandenvironmenttoGET /v1/search, in the same plain form, so a narrowed search works without JavaScript and is a link. Time starts on any time, which is what the API searches without a window; a custom range the API would refuse is set aside with a note saying the search spans all time. The page says what the results are narrowed to, and that the window is on a journey’s last activity. Each result row names its environment when the API’s search rows carry it (F-036); against an API whose rows do not, it shows none. On an installation with more than one project and none chosen, the search box still shows, with only “all” environments and a link to choose a project; a search goes to the project picker and back, as before. - A search row names its environment (F-036,
docs/API_SPEC.mdsection 5):environment, the environment’s name, as aGET /v1/journeysrow already had. The admin token’s search spans every environment of the project, so the same identifier recorded in development and in production came back as two rows with nothing to tell them apart, and a search narrowed byenvironmentcould not say which one it gave. Both lists now build their rows from one presenter, so the two cannot drift again. Measured at 200,000 journeys, the join this needs on every search costs nothing outside the run-to-run spread. - An event read says which aliases it stated (F-042,
docs/API_SPEC.mdsection 9).GET /v1/events/:eventIdgainsaliases, each as the journey read shows it,{ type, displayValue, displayable }, from the same stored alias and masked the same way (ADR-053), so anidentifiedevent read on its own finally says what it identified.[]means the event stated none;nullmeans the server did not record it, which is every event stored before this release. A dry run’sstored.eventcarries the same field. Aliases are still stored once per journey; migration 020 addsjourney_events.stated_alias_ids, the ids of the alias rows the event stated, written with the event’s own insert. Ingestion now stores an event’s aliases before the event, which costs an event with aliases one more statement: 2.3 to 2.7 ms at the median, measured in process. - A failed journey names the step that failed it (F-047, ADR-063,
docs/API_SPEC.mdsection 5).GET /v1/journeys/:journeyId, each item ofGET /v1/journeysandGET /v1/search, and a dry run’sstored.journeygainfailedStep: thenameof the failing event last in timeline order among the failures since the journey last became failed, and null wheneverstatusis notfailed.lastStepstill follows the timeline’s last row, so while a retry is in flight it names the retry’s latest step andfailedStepstill names the step that failed. A successful retry that clears the failure and a completion clear it. Migration 021 adds the four columns behind it; it costs no statement, being part of the update that sets the status. - A timeline row names the build that recorded it (F-043,
docs/API_SPEC.mdsection 8).GET /v1/journeys/:journeyId/eventsrows gaindeploymentMetadata, the event’sdeploymentas the event read returns it, or null. Whether a journey’s events all came from one build used to take one full event read per event, each carrying its whole payloads. Measured at 10,000 events in one journey, a page of 100 goes from 0.068 to 0.100 ms in the database and from 15 to 28 kB; listing the distinct builds on the journey read instead would have read every event of the journey on every read, 5.0 ms at that length. - The journey page. Headed by the journey’s label when it has one, with the
entity type and identifier beneath, and a back link to the list it was opened
from.
GET /v1/journeys/:journeyIdreturns the environment’s name,label,lastStep, and each alias with adisplayablefield; masked values are marked as masked. - An interactive timeline. Every event of a journey, however long: the page
renders the first hundred and reads the rest on request. Each row leads with the step’s name, with the operation as
a badge and the service after it, and carries a full UTC timestamp; the date
appears when a journey spans more than one day, and an event whose recorded
time is more than two minutes from its arrival carries a clock warning. Filter
to one service or to failures, move through events with the arrow keys while
the detail panel follows, and turn on Live to follow a journey that is still
recording. The address keeps
?event=in step. The browser talks only to session-checked route handlers in the web app, never to the API (ADR-029). - Field-level transformation diffs, which is the point of the product: what a step received against what it produced, shown as a field table rather than a text diff (ADR-030). Long diffs collapse to eight rows behind a button.
- Replay to development destinations (ADR-008, ADR-032): destination management, request preparation, and safety checks (an exact host allowlist, DNS pinned to the resolved address, refusal of the cloud metadata range, a header blocklist, response caps and timeouts), with the prepare and result pages. The payload is reviewed before sending and sent as recorded.
- A read-only CLI,
packages/cli:search,journey,event --diffandprojects, over HTTP, with--jsonon everything (pnpm cli). - No blank error pages. An error boundary explains the likely cause, and a 401 says the token does not match.
Operating it
Section titled “Operating it”GET /readysays what the API is running (docs/API_SPEC.mdsection 14):version,commitwhen the build recorded one, andsource, which isbuildwhen the published image baked the values in andpackagewhen nothing was baked in, so a source run or a hand-built image says so instead of naming a release it is not. All three are on the 503 answers as well as the 200, because when something is wrong the first question is what is running. The values come from theWAYSCRIBE_BUILD_VERSIONandWAYSCRIBE_BUILD_COMMITbuild arguments thatscripts/publish-image.shfills with the release tag and the commit; there is no runtime shell-out to git, which the image has no history, working tree or binary for.GET /healthis unchanged and stays a bare liveness check.- The web app says what it is running. A line under every signed-in page
names the web app’s version and commit and the API’s, read from the API’s
GET /ready, and says when the two are different builds, which is what a partial upgrade looks like (F-045). The web image takes the sameWAYSCRIBE_BUILD_VERSIONandWAYSCRIBE_BUILD_COMMITbuild arguments as the API image; without them it says “not a release build”. An API that does not answer leaves the line saying its version is unknown, and the page renders as usual. - Bring your own database (ADR-037).
DATABASE_URLpoints at a PostgreSQL 15 or later that your team already runs. Migrations need privileges on their own schema only and install no extensions.infrastructure/compose.bundled.yamlis an overlay that runs PostgreSQL alongside, for evaluation and local work. - Install shapes.
infrastructure/compose.published.yamlpulls the images and migrates on first boot. It requiresFLIGHT_RECORDER_VERSION, the release tag to run, and has nolatestfallback, so a pull cannot move the database across a release nobody chose. A Helm chart runs the stack on a local single-node cluster (ADR-042,deploy/helm/README.md); its migrate Job retries a connection failure, retries any other failure once withmigration failed ... see the error above, and stops aftermigrations.activeDeadlineSeconds, 30 minutes by default. project:createandproject:list, andkey:create,key:revoke,key:list, all in the API image, so a new installation needs no checkout.key:createandkey:revokewriteapi_key.createdandapi_key.revokedaudit rows in the same transaction as the key, naming it by prefix (docs/SECURITY.mdsection 13).doctorchecks an installation and says what to fix, one line per check (PASS,WARN,FAIL,SKIP): the database and its PostgreSQL version, pending migrations (naming a missing schema grant as such), published default secrets, stored data the configured keys cannot read, projects and unrevoked keys (warning while the published demo key is active), journeys stored across environments, secret-looking names in a sample of stored payloads, and with--api-keyand--api-url, whether a key authenticates and the API reports ready. Exits 1 when anything failed, and prints no secret beyond an API key’s prefix. It is in the API image besidekey:create, and runs aspnpm run doctorfrom a checkout (docs/OPERATIONS.mdsection 12). It takes the key to check fromWAYSCRIBE_API_KEYwhen--api-keyis absent, so the key need not sit in the container’s process list; the flag wins when both are given. A variable that is set but empty, which is what a Compose file’sWAYSCRIBE_API_KEY: ${WAYSCRIBE_API_KEY}gives it on a host without one, reports the key check asSKIPwith that reason rather than leaving it out of a report that then read as all passed (F-032).key:create --jsonprints one JSON object with the key, its prefix, the project and the environment, and nothing else, so a script capturing a new key parses no prose. Without the flag the human form is unchanged.--helpfor the database CLI and for each of its commands, listing the arguments and flags, includingkey:create --jsonand the four fields it prints (apiKey,keyPrefix,projectSlug,environmentName). The top-level help names both ways to run the CLI:node packages/database/dist/cli.jsin the API image, andpnpm run <script>in a checkout. It replaces a usage line that namedtsx src/cli.ts, which the image does not have and which appeared only for an unknown command (F-033). Help needs noDATABASE_URLand never runs the command, however many--pnpm and the operator put before it;--helpor-hafter the value separator is refused rather than read as a value. Every flag is declared once in the CLI’s command registry, which the parsers read their flags from by type and every usage line and help text is built from. A command that takes no flags now refuses one, askey:createanddoctoralready did, rather than ignoring it, and a name that begins with a dash goes after--forproject:createandkey:createas it does for the deletion commands.help <command>prints a command’s help, as does a barehelp, in any letter case, on every command butproject:create,key:createandkey:list; on the deletion commands a value that is reallyhelpgoes after--. An argument containing a dash other than the ASCII hyphen, which smart punctuation or a full-width keyboard makes of a typed--, is refused, and so is an argument beyond those a command declares. With the--of--helpturned into an em dash,rollbackused to roll back andkey:revoke <prefix>to revoke.ENCRYPTION_KEY_FILE,ENCRYPTION_KEY_PREVIOUS_FILEandADMIN_TOKEN_FILEread each value from a file at startup instead of from the environment, the way Docker’s own secrets mechanism mounts one.infrastructure/compose.secret-files.yamlis an overlay that wires this up for either Compose stack. The API, the web app,doctorand the key rotation commands all read them. Whitespace at the end of the file is ignored, an empty file is refused rather than read as an unset setting, and a setting given both as a variable and as a file is refused by name with no value printed.APP_URLandAPI_URLread from the environment in the Compose files, with the same localhost defaults, so a second stack on other ports can say where it is reached. Every other setting in those files already did.- Both images declare a health check, so
docker compose up -d --waitwaits on a served request rather than a started process: the API answersGET /healthand the web app answers its ownGET /health, which loads its configuration and answers 503 when that fails, so for the web app “healthy” also means configured. Each is probed every two seconds during its start period, which returns--waitabout two seconds sooner on a cold start (11.2 s to 8.8 s, measured on a laptop). - The web app refuses to start misconfigured, as the API does. It loads its
configuration once when the server starts and, when that fails, writes the
reason naming the setting and never its value, and exits 1. Before this, a
web container given both
ADMIN_TOKENandADMIN_TOKEN_FILE, or a token file that was not there, started, passed a health check that probed/login, and failed the first sign-in with a 500;up --waitreported itHealthy. It now reportscontainer <name> exited (1)within a second (F-030, measured on the built image in both cases).API_URLmust be anhttp://orhttps://URL:api:8080, the host and port without a scheme, used to be accepted as a URL with the schemeapi:, and a container started with it was healthy while every data page said the API could not be reached. ENCRYPTION_KEYrotation without losing data (ADR-044). Every encrypted value names the key that wrote it;ENCRYPTION_KEY_PREVIOUSkeeps old data readable and searchable, and API keys authenticating, through a grace period;rotate:reencryptmoves stored data across in resumable batches; androtate:statusexits 0 when the previous key can come out (docs/OPERATIONS.mdsection 6).- Deletion on demand (ADR-045).
delete:journey,delete:identifier,delete:rangeanddelete:destination, and the admin routesDELETE /v1/journeys/:journeyId,POST /v1/erasuresandDELETE /v1/replay-destinations/:destinationId, with a confirmation page on each journey. Deletion is hard and admin-only; the selecting deletions have a dry run; every deletion writes its audit row in the same transaction, and an erasure’s row holds the search token, never the value (docs/OPERATIONS.mdsection 8). - Retention, swept hourly inside the API process, per environment, behind an
advisory lock (
docs/OPERATIONS.mdsection 7). - Prometheus metrics, on their own port (ADR-047).
METRICS_PORT, unset by default, serves/metricsand nothing else: requests by route pattern, events accepted, duplicate and rejected, query timeouts, pool connections, retention outcomes, the boot check’s unreadable counts, memory and event loop lag (docs/OPERATIONS.mdsection 13). - Indexes built without blocking ingestion. Migrations 013, 014, 016 and 019
build their indexes concurrently.
migrate:unlockreleases the migration lock a killedmigrateleaves behind (docs/OPERATIONS.mdsection 10). - Measured sizing.
scripts/measure-storage.mjsandscripts/measure-journey-list.mjsreport disk per event by capture mode and journey list latency against a scratch database;docs/OPERATIONS.mdsection 10 has the results and a sizing formula. - An upgrade test gates every release.
scripts/upgrade-test.mjsrecords journeys, aliases, a diff, an error and a replay destination with an earlier build, then runs this build’s migrations and API against the same database and checks that everything reads back.publish-imagesneeds it (docs/OPERATIONS.mdsection 4). - Signed images with an SBOM.
publish-imagespushes each image by digest, attaches a CycloneDX SBOM per platform as a cosign attestation, signs with Sigstore keyless signing from the pipeline’s GitLab OIDC token, verifies, and only then creates the version tag andlatest. The SDK is published with npm trusted publishing and provenance throughscripts/publish-sdk.sh. Release jobs run only forvMAJOR.MINOR.PATCHtags;v*tags must be protected before the first release (docs/OPERATIONS.mdsection 11,SECURITY.md). - The demo, four services proving the reference journey end to end, and
pnpm test:demo, which asserts all ten events, the diff, the retries, and the dead-letter state against a running stack.examples/instrument-a-serviceruns from a clean clone and lists every step. - Operations and security documentation, a security disclosure policy, and the decision log. The 2026-08-09 first-contact audit and what it changed in how this is tested are in docs/WHAT_RUNNING_IT_FOUND.md.
Changed
Section titled “Changed”-
counters()keeps settings and options apart.rejectedSettingsnow names only whatcreateRecorderrefused, and is fixed once it returns; a newrejectedOptionsnames what a later call was refused (entity,context,journeyId,journeyIdSecret,entityFallback,displayable). A correctly configured process used to end its shutdown line withrejected settings: journeyIdafter one odd call (F-038, ADR-062).configurationErrorsstill counts every report.journeyIdFornow namesentitywhen it refuses one, and refuses an entity whose type or id is empty, as its documentation and the protocol already said. The derivation fixture lists those entities under a newrefusedEmpty, so another SDK’s conformance run checks it too. -
deploymentis reported by field. A refused field is nameddeployment.gitCommit,deployment.versionordeployment.image; keys the protocol does not have aredeployment.*, never by their own names; anddeploymentmeans events carry none of it. So a partial refusal no longer reads like a total one (F-031, ADR-062). A field that is only whitespace is now refused as empty,{}and{ gitCommit: undefined }are now reported, andconstructorortoStringbeside a valid field is now caught. -
An error whose fields cannot be read no longer costs its step.
record({ error })with amessage,type,codeorstackgetter that throws, or a revoked Proxy as the error, lost the whole event with onecapture_error. Each field is now read on its own; a field that throws costs that field. A message that cannot be read, or is not a non-empty string, is sent asThe error's message could not be read.instead of a value the server would refuse, and onlymessage,type,codeandstackare sent, each a string. -
createRecorderno longer throws or hangs for a list setting it cannot read. A revoked Proxy given asdeployment,redactorknownSafeNames, an array Proxy whose reads throw, an array with a hostileSymbol.speciesor an overriddenfilter, threw out ofcreateRecorderinto the host’s startup, against SDK-6, and an array Proxy claiming alengthof a trillion hung it.redactandknownSafeNamesare now copied by index into an array the SDK owns, at most 1,000 entries, and nothing of the host’s runs after that. A list that cannot be read, or holds more than 1,000 entries, is reported as that setting, andredactkeeps the built-in secret names. -
An error message that looks like personal data is warned about, as a journey label and a displayable alias already were (F-041, ADR-062). A thrown error’s, a
FailureReason’s,fail()’s andrecord()’s message is masked for credential shapes only, and a timeline shows it to every reader; an email address or an international telephone number in it now raisespersonal_data_in_public_valuewithdetail.fielderrorMessage, and the message is sent unchanged. A stack is not examined. The warning is now given once per process, field and shape, sopersonalDataInPublicValuesis at most 6 rather than 2, and a warning about an error message never silences a later one about a label or an alias. The email shape no longer matches a module path, a versioned package, a masked URL, a git remote, an ssh target or an image digest, and a timezone offset such as+0000is not a telephone number. -
Every event names the SDK that recorded it. The protocol’s
runtimeblock gains an optionalsdk: { name, version, commit? }, exported asruntimeSdkSchema(limits 128, 64 and 128), and the Node SDK now sendsruntimeon every event:language"node",versionthe Node version, andsdkwith@wayscribe/node, the package version and the commit it was built from, both baked in when the bundle is built. So during an upgrade, which services still run the old SDK is on every event, where both builds used to say0.1.0and send noruntimeat all (F-046, ADR-063, SDK-64). The commit comes first frompackages/sdk-node/BUILD_COMMIT, whichgit archivefills through a newexport-substrule in.gitattributes, then fromWAYSCRIBE_BUILD_COMMITorCI_COMMIT_SHA(a malformed one fails the build), then fromgit rev-parse HEADin the package’s own repository. Nothing is read from host settings, andhostnameandprocessIdare not sent. The protocol version stays0.1: a server from before this stripsruntime.sdkand stores the rest, and each event is about 152 bytes larger with a commit and about 100 without one. -
droppednames its cause, and a reply with no verdict counts toward the breaker.counters().droppedByCausecountsdroppedby the diagnostic’s code,queue_full,after_shutdown,shutdown,retry_budgetandno_verdict, every key present from creation at zero and summing todropped; the type of its keys is exported asDroppedCause. Four faults that ended with the samerecorded 12, dropped 12now read apart (F-048). A send in which no reply gave a verdict for any event now counts toward the circuit breaker, so a proxy answering 2xx with the wrong body opens it after five sends instead of losing every event with the breaker shut: Leadline measuredrecorded 16000, dropped 16000andbreakerOpened0. Such a send reports notransport_error. A reply with some verdicts resets the count as before (ADR-063, SDK-65). -
The telephone shape finds a number in a field and not a signed count. The
+of a telephone number may now follow=,:, a quote,,,;,>or)as well as whitespace,(,[and<, sophone=+19195551234,tel:+19195551234and{"phone":"+19195551234"}raisepersonal_data_in_public_value, which they did not. Digits written as one unbroken run now need 10 to 15 rather than 8 to 15, soReceived +12345678 bytesno longer does; a number with separators still needs 8. A number of 8 or 9 digits written with no separator is no longer found. A+and four digits is skipped as a timezone offset only when it is one, hours 00 to 14 and minutes 00, 15, 30 or 45, so+0530 2026is still skipped and+4930 1234567is now found (ADR-063). -
Four SDK declarations say what the code does.
WrapResultsays the assignment of a second implementation needs no cast and its body’s return still does (F-037).ContinueJourneyOptionsnames all four steps of the journey id, the id derived underjourneyIdSecretincluded (F-039).metadataFromsays it runs on a resultisFailurecalls a failure, not when the callback throws or rejects, and once per call or per journey of anacross()group (F-040).FailureReasonsays its message is masked for credential shapes and not for personal data (F-041). Each statement is pinned by a test. No behaviour changed. -
hasJourneytakesunknown. It was declared as taking aPayloadEnvelope<T>while documented as taking anything, so a body off a queue, typedunknown, needed the cast the guard exists to remove (F-034). It is nowhasJourney(envelope: unknown): envelope is ContextEnvelope<unknown>, with no type parameter, since the guard never readsdataand a type argument would assert the payload’s type unchecked (ADR-062). APayloadEnvelope<T>still narrows toContextEnvelope<T>inside the guard and toNoContextEnvelope<T>in itselse. Its documentation now says thatfalsecovers both a value that is not an envelope and an envelope with no journey. Nothing changes at run time. -
injectPayloadreturnsPayloadEnvelope<T>, notContextEnvelope<T>. Without a context to inject it produces an envelope with an empty_wayscribe, which did not satisfyContextEnvelopeand which the SDK cast its own value to get past (ADR-060).PayloadEnvelope<T>isContextEnvelope<T>or the newNoContextEnvelope<T>, so the declared type is now what the call can actually return. A new exported type guard,hasJourney(envelope), narrows one toContextEnvelope<T>: a nestedjourneyIdcheck does not narrow a union, which the SDK README’s propagation section explains. -
Capture is about a quarter faster. Fitting an event to the server’s limits (ADR-051) had made a wrapped call about 40 percent slower: it checked the whole event a second time and walked each payload again to cut long strings. The event is now measured with plain serialisation when it is certainly within its budget, and strings are cut in the walk that makes a payload storable, with the same results. In a tight loop on an Apple M3 Pro, a 1 KiB
transformwent from 41 to 32 µs and a 64 KiB one from 2,122 to 1,769 µs. While the circuit breaker is open the recorder no longer starts a send for every event recorded, which cost about 6 µs a call against a refusing endpoint. -
Events arrive in order after an outage. Those failed sends overlapped and put their batches back out of order, so once the endpoint recovered a few batches arrived out of order, and with the queue full a batch from the middle of the outage could arrive after thousands of newer events had been dropped. The events kept are now the newest, and they arrive in the order recorded.
-
A payload far over budget is refused quickly. The size check serialised a payload in full before comparing it with the budget, and shared references expand when serialised: an object 24 levels deep holding the next level twice took
record()about 2 seconds and 245 MB. The check now stops once the payload is certainly too large. -
A long string replaced by a colliding key is not reported as cut. Two keys that differ only by a NUL or a broken character are stored as one, and a long value the later one replaced no longer counts in
payloadsTruncated. -
An event whose payload is a function or a Symbol is sent. When another payload pushed such an event over budget, weighing it threw and the event was lost as a capture error.
-
A blank required setting is reported as missing (SDK-60). An empty or whitespace-only
endpoint,apiKey,serviceNameorenvironment, such asprocess.env.FLIGHT_RECORDER_API_KEY ?? ""with the variable unset, now printsconfiguration_error: <setting> is empty, ...once per process, even withlogDiagnosticsoff, instead of only a 401 from the server. -
CI tests the versions the project claims. The SDK and CLI run on Node 22.12.0 and 24, including
importandrequire()of the packed tarball in fresh projects, and the integration suite runs on PostgreSQL 15, 17 and 18.doctorno longer warns on PostgreSQL 15 or 16 and warns instead on a release newer than 18. The CLI’senginesfloor is Node 22.12, matching the SDK. The README has a Supported versions table, checked against the CI configuration. -
The Node SDK’s public API is settled for its first release (ADR-056). The wire format is unchanged, but a host that installed the SDK from an earlier tarball has to follow these renames:
Before After recorder.consume({ context, entityFallback })recorder.continueJourney({ context, entity })recorder.continueJourney(context)recorder.continueJourney({ journeyId, entity }), the same object;labelmay be addedrecorder.diagnostics()recorder.counters()recorder.toQueueAttributes(context)recorder.injectSqsAttributes({}, context), which copies the attributes it is givenrecorder.fromQueueAttributes(attributes)recorder.extractSqsContext(attributes)recorder.wrapPayload(payload, context)recorder.injectPayload(payload, context)recorder.unwrapPayload(body)recorder.extractPayload(body)journey.fail(name, error, metadata)journey.fail(name, error, { metadata })journey.identify(aliases, { displayable })journey.identify(aliases, { displayableAliases })startJourney({ displayable })startJourney({ displayableAliases })option maxPayloadBytesmaxEventBytesoption propagatepropagationdiagnostic kind breaker_open, printed[flight-recorder] breaker_open:breaker_opened{ kind, reason, detail? }{ kind, code, reason, detail }: match oncode, never onreasondelivered_first’sendpoint,accepteddetail.endpoint,detail.acceptedinsecure_endpoint’sscheme,hostdetail.scheme,detail.hostpayload_omitted’sdetail.reason(payload_too_large,max_depth_exceeded,max_keys_exceeded,projection_failed)code(too_large,too_deep,too_wide,projection_failed, andunserialisable, now also reported for a payload whose getter throws)droppedreasonsqueue_full,no_verdict: ...,shutdown: ...codes queue_full,no_verdict,shutdown, andafter_shutdown,retry_budgetrejected’sdetail, the server’s errordetail.serverError; a whole request refused is coderequest_refusedwithdetail.eventsanddetail.httpStatuscapture_errorandtransport_errordetail, the thrown valuedetail.errorkeysDroppedcounted keyscounts key_droppedreports;detail.keysstill counts keysno such counter recorded;sent + rejected + dropped === recordedaftershutdown()types FailureDiagnostic,FailureKind,TraceContextremoved; one interface per kind ( DroppedDiagnostic,PayloadTruncatedDiagnostic, …)engines.node>=20.19.0>=22.12.0pnpm --filter @flight-recorder/node packpnpm --filter @flight-recorder/node run pack:release <absolute directory>An option passed under its old name is reported as
setting_renamed, andmaxPayloadBytesandpropagateare printed once per process. A TypeScriptswitchoverkindshould keep adefaultbranch: new kinds and codes may be added in any minor release. -
The SDK no longer converts numeric strings in its options (SDK-60).
maxBufferedEvents: "5000", as read fromprocess.env, falls back to the default with aconfiguration_errorreport instead of taking effect. -
GET /v1/journeysrefuses a query key it does not read, with400 invalid_querynaming the start of the key and listing the parameters the route reads. A misspelt filter such asentity_type=orderused to return an unfiltered list that looked filtered. -
GET /v1/searchrefuses a query key it does not read, with400 invalid_querynaming the start of the key and listing the parameters the route reads, asGET /v1/journeysalready did. This changes an answer:?q=CUST-1&status=failedwas a200that silently ignoredstatusand is now a400, which is the point, because a filter that is dropped without a word returns an unnarrowed search that looks narrowed. A caller that sends onlyq,limitandcursor, which is every caller in this repository, sees no change; one that sends anything else now sees a refusal. A parameter name holding a NUL is refused without being echoed back. -
limitis refused, not reinterpreted (F-029), onGET /v1/search,GET /v1/journeysand a journey’s timeline. It was read withparseInt, which stops at the first character that is not a digit, so a repeatedlimit=1&limit=99returned one row andlimit=99&limit=1ninety-nine, andlimit=2%005returned two. Nowlimitgiven more than once, or holding a NUL, is400 invalid_querywith the words every other parameter gets (limit must be given once.,limit must not contain a null byte.). This changes an answer: a value that is not a whole number of at least 1, such asabc,0,-1or5abc, is refused withlimit must be a whole number of at least 1; above 100 it is read as 100., where it used to become 25 without a word. A whole number above 100 is still read as 100, as before, andnextCursorsays whether more remains. Omitted or empty is still 25. -
A journey’s timeline refuses a query key it does not read, as search and the journey list do:
GET /v1/journeys/:journeyId/events?limt=5is400 invalid_querynaming the key and listinglimitandcursor, where it returned the default page as if the key had been understood. This lands with thelimitchange, so the route changes its error behaviour once. -
The read-only CLI reads
--limitstrictly.wayscribe search x --limit 5abcsent a limit of 5, because it too was read withparseInt. A value that is not a whole number of at least 1 is now refused before anything is sent, with--limit must be a whole number of at least 1, and--helpsays the server reads one above 100 as 100. -
The refusal of a future
sincestates the real rule (F-035), onGET /v1/searchandGET /v1/journeys:since must not be more than 60 seconds ahead of the API's clock.It saidsince must not be in the future., which a caller whose clock ran 30 seconds fast saw contradicted by a200, and it left out the tolerance that explains a refusal caused by clock skew. The check itself is unchanged. The message is built from the tolerance, so the two cannot drift apart. -
Dry runs that share a journey or an event id wait for each other (ADR-063,
docs/INGESTION_CONTRACT.mdsection 8). Two dry runs naming the same journeys in opposite orders deadlocked, and PostgreSQL cancelled one, which answeredstorage_errorfor events a real send would store; a reviewer saw it in 50 runs of 50. Two sending the same event ids under different journeys did the same, 20 of 20, because an event id is unique per project whatever the journey. A dry run now takes a transaction-scoped advisory lock per distinct journey id and per distinct event id, the two kinds under different first keys, one statement each, journeys first and each kind in ascending key order, before its first event, so the second waits at its start. On a 100-event dry run with 100 distinct ids the event locks add about 16 ms to about 420 ms. A wait pastDATABASE_STATEMENT_TIMEOUT_MSanswers the request503 query_timeout. Batches sent for real take no such lock. -
The journey id shapes are documented (F-049, ADR-063,
docs/EVENT_PROTOCOL.mdsection 4). The Node SDK’s random ids arejrn_and a lowercase hyphenated UUID, 40 characters; its derived ids arejrn_and 32 lowercase hex characters, 36 characters. The section recommendedjrn_<uuidv7>, which neither is. A journey id stays an opaque string of 1 to 128 characters, and a reader must not parse or validate its shape. -
A successful retry clears a failed journey (ADR-061). A
retriedevent carrying no error returns the journey’s status fromfailedtoactiveinstead of leaving it failed until something else says otherwise. An SDK records a retried call asretriedwhichever way it comes out (ADR-022), so a step that failed on its first attempt and succeeded on its second used to leave the journey failed untilfinish()landed. It clears rather than completes: a journey that retried successfully and then died without finishing must not read as completed, socompletedstill means acompletedoperation at or after the newest event’s timestamp. A retry that fails again is still a failure, an older successful retry still cannot clear a newer one, and a journey already completed is never knocked back. -
Both ingestion routes refuse a query parameter they do not know, with
400 invalid_querynaming the key.POST /v1/eventsaccepts none andPOST /v1/events/batchaccepts onlydryRun, so?dryrun=truecan no longer store a batch the client believed it had only validated. -
Refusals before a route runs use this API’s codes. They were Fastify’s
FST_ERR_CTP_BODY_TOO_LARGE,FST_ERR_CTP_INVALID_MEDIA_TYPE,FST_ERR_CTP_INVALID_JSON_BODYandFST_ERR_CTP_EMPTY_JSON_BODY; they are nowpayload_too_large,unsupported_media_typeandmalformed_json. The HTTP statuses and the error body are unchanged. -
Three protocol error codes are gone (ADR-049).
missing_required_field,invalid_timestampandinvalid_operationwere listed and never sent; those refusals areinvalid_eventwith the failing field indetails. Code that importedPROTOCOL_ERROR_CODES.missingRequiredField,.invalidTimestampor.invalidOperationno longer compiles; treat an unrecognised code by its HTTP status. -
The Recent page is now the Journeys page (ADR-054).
/recentredirects to/journeyswith its query string, addingstatus=failedwhen the link named no status, which is what Recent showed. -
The database is yours by default (ADR-037).
DATABASE_URLis required, and the bundled database moved to theinfrastructure/compose.bundled.yamloverlay. See the upgrade notes. -
infrastructure/compose.yamltakes its keys from files.ENCRYPTION_KEYandADMIN_TOKENcome frominfrastructure/defaults.envand then the repository-root.env, which wins, instead of the shell.compose.published.yamlstill reads the shell. -
The Helm migrate Job says why a migration failed, and is bounded. It printed
database not readyafter every failed attempt and retried every failure 30 times. See the upgrade notes for its deadline. -
Database CLI commands that take no flags refuse one.
migrate --dry-run, for one, used to ignore the flag and migrate; it now printsUnknown argument: --dry-runwith the command’s usage and exits 1, changing nothing. A script passing such a flag should drop it.key:createandproject:createlikewise refuse an argument beginning with a dash, whichkey:createtook as the key’s name; such a name goes after--, whichproject:createused to fold into the name. Every command refuses an argument it does not declare, whichmigrate,rollback,seed,key:revokeand the others without a parser of their own used to ignore.
Security
Section titled “Security”- Secrets need not sit in the container’s environment. With the Compose
paths
ENCRYPTION_KEYandADMIN_TOKENare part of the container’sConfig.Env, so anything able to rundocker inspecton the host can read the key that decrypts every stored payload and the token that signs every admin session, which is the access starting the stack already needs. The trade-off is stated next to the settings table (docs/OPERATIONS.mdsection 6) and indocs/SECURITY.mdsection 7, and the_FILEsettings above are the Compose equivalent of theexistingSecreta Helm install already has. The boot warning about published default secrets is found in the values the API is running on, so a default that arrives through a file warns exactly as one in the environment does. - The secrets scanner is pinned. The
secretsjob ranzricethezav/gitleaks:latest, so a new gitleaks release could change the gate without anyone choosing it. It now runsv8.30.1, pinned by digest, as Trivy, Syft and cosign already were. - LICENSE and NOTICE ship with the package and the images. The npm package
and the API and web images now include the project’s LICENSE and NOTICE, as
Apache-2.0 asks of anyone redistributing it; in the images they are at
/licenses/. The release checks fail if either file is missing, and the web image now carries the same OCI labels as the API image. - Next.js telemetry is off wherever Next runs.
next buildreports anonymous usage data to Vercel unlessNEXT_TELEMETRY_DISABLEDis set, and the README’s quick start builds the web image on the reader’s machine. The web Dockerfile (build and runtime stages), the web package’sdev,buildandstartscripts, and CI all set it. The running services send no telemetry; building the images still downloads base images and packages. - Redaction matches built-in secret names at any depth (ADR-035), in the SDK
before an event leaves the process and again at ingestion, in every capture
mode. Rules of the form
**.namematch a key wherever it appears; a bareauthorizationstill matches the top level only. A secret name is one name however it is spelled (ADR-039). - Header credentials are redacted in the shapes clients produce: a
two-element
[name, value]array, a plain object with a stringnameorkeybeside avalue(other fields are kept), a flat string array that reads as a header list, Node’srawHeaders, and aName: valueline in a CRLF-delimited header block, including one the SDK cut to the string limit. A value that is itself a header name is kept.SECURITY.mdsection 4 lists exactly which shapes are covered. - Webhook signature headers are redacted by default (ADR-055).
stripe-signature,x-hub-signature,x-hub-signature-256,x-slack-signature,x-hubspot-signature,x-hubspot-signature-v3,x-twilio-signatureandx-shopify-hmac-sha256are built-in secret names, in the SDK and on the server, in every capture mode. A stored signature with its body is a request the receiver accepts, and GitHub’s has no timestamp. - A warning for secret-looking field names no redaction rule covers
(ADR-055, SDK-61, SDK-62). The SDK reports
unredacted_secret_namewhen an event carries a number or a plausible string under a name that looks like a secret, as an object key or in a header shape, naming the field, the name and its path and never the value. It prints one line per process and name even withlogDiagnosticsoff, countsunredactedSecretNames, and sends the event unchanged: nothing is redacted on a guess.knownSafeNamessilences a false positive without changing redaction. The rule islooksLikeSecretNameandlooksLikeSecretValueinpayload-security, written out inSDK_SPEC.mdsection 13.doctorruns the same check over a sample of stored payloads, for senders that are not the Node SDK. - Credentials inside error text are masked by shape (ADR-046), by the SDK
before sending and by ingestion before storing: URL userinfo, Slack and
Discord webhook URLs,
Bearer,BasicandDigestcredentials, values assigned to a secret name, JSON Web Tokens, PEM and PGP private keys, and provider-prefixed keys. It does not guess at entropy;SECURITY.mdsection 4 lists the known misses.metadataand payload strings keep name-based redaction only. - Stack traces are stored only under full capture. Ingestion drops
error.stackunless the environment’s capture mode isfull-payloadandALLOW_FULL_PAYLOAD_CAPTUREis set, and masks a stack it keeps. The Node SDK does not send one. - What is stored in plain text (ADR-054): payloads, after redaction, as
jsonb; journey labels, which are not redacted and must not hold personal data; and copies of the values of aliases marked displayable. Entity ids and masked aliases are encrypted and tokenised, andqnever matches them. When an alias is masked the live row’s copy is removed at once, but earlier row versions keep the text until vacuum, it stays in WAL, replicas and backups, and destroyingENCRYPTION_KEYdoes not cover it;docs/SECURITY.mdsection 6 says how to purge it. - The stored content hash is keyed (ADR-048): an HMAC-SHA256 under a
subkey of
ENCRYPTION_KEY, stored ash1.<keyId>.<hex>, so a database reader cannot confirm a guess at a masked value by hash match. - An API key cannot write into another environment’s journey (ADR-038).
Such an event is refused with
409 journey_environment_mismatchand nothing is stored for it. Journey ids an application chooses itself must be unpredictable, and a journey id propagated from one environment to another is refused (docs/EVENT_PROTOCOL.mdsection 4). - Replay does not leak its destination’s credentials. A replay whose
destination headers cannot be decrypted is refused and audited as
replay.blocked, never sent without them. A run stores each destination header, and any blocklisted header, by name with the value[REDACTED], and each destination header value of 8 or more characters is replaced in the stored response body and error message (exact matching only). A destination’s audit row does not record its base URL. - Replay’s default allowlist is
localhostincompose.published.yamland the Helm chart, nothost.docker.internal, which reaches every service on the Docker host. The development stack andvalues-local.yamlkeep it (docs/OPERATIONS.mdsection 9). - Logs carry no request values or row contents. Request log lines carry the
path and parameter names with every value
[REDACTED], so a searched identifier is not logged; a request matching no route is404 not_foundnaming only the path. A request Node’s parser rejected is logged without its raw bytes. A database error is logged without PostgreSQL’sdetail,whereandinternalQuery, which can print the refused row. - Admin token guesses are throttled. The API counts failed authentication
per source address on every route that accepts the token and answers
429 too_many_attemptsafter five failures in a minute, for five minutes; ingestion is not throttled. The web login’s limiter keys on the socket address.TRUSTED_PROXY_COUNT(default 0) honoursX-Forwarded-Forthat many hops from the right. An IPv6 address counts as its /64, and each throttle’s memory is bounded (docs/OPERATIONS.mdsection 9). Authorizationmust be exactly the scheme, one space, and the token. Anything after the token is401.- Redirects stay on the host. The project picker’s return path is checked
after normalisation, and every redirect the web app builds is a 303 with a
path-only
Locationheld to its own host, which also keeps sign-in working behind a TLS proxy that sends noX-Forwarded-Proto. - The web interface sends a Content-Security-Policy allowing scripts only
from its own origin and by a per-response nonce, with
frame-ancestors 'none',base-uri,form-actionandstyle-srcall'self', andobject-src 'none', plusX-Frame-Options: DENY,Referrer-Policy: no-referrerandX-Content-Type-Options: nosniff. A browser test fails on any policy violation. - The Helm chart runs every pod locked down: non-root users,
seccompProfile: RuntimeDefault, no privilege escalation, all capabilities dropped, and a read-only root filesystem.networkPolicy.enabled, off by default, adds a NetworkPolicy per pod; replay destinations go innetworkPolicy.apiExtraEgress(deploy/helm/README.md).
Upgrade notes
Section titled “Upgrade notes”These apply to an installation or a host application built from an earlier
development build of main. A new installation can skip them.
-
Upgrade the server before the services. A new SDK sends
runtime.sdk, which a server from before ADR-063 strips before it takes the event’s content hash. An event whose first delivery reached the old server and whose response was lost, resent after the server was upgraded, is answeredevent_id_conflict: it is stored, and the SDK counts itrejected. Upgrading the server first never meets this. -
counters()hasdroppedByCause, and a proxy that answers without verdicts opens the breaker. A test that compares the whole counters object adds the new key. A service behind a proxy that rewrites the API’s replies now showsbreakerOpenedabove zero and loses events asqueue_fullorshutdownonce the queue fills or the process ends, where it lost them all asno_verdictbefore. -
rejectedSettingsno longer holds call-time names. A test or health check that expectedentity,context,journeyId, or ajourneyIdSecretthat a call needed but was never configured, inrejectedSettingsreadsrejectedOptionsinstead. A check that stops recording on any refused setting keeps working, and can now let a singledeployment.<field>through while still stopping ondeployment. -
deploymentproblems have new names. One that was reported asdeploymentis nowdeployment.<field>,deployment.*,deployment, or several of them, in that order.{ gitCommit: process.env.GIT_SHA }with the variable unset now reportsdeployment; it sent nothing before too, in silence. A value that is only whitespace is refused instead of being sent. -
hasJourneytakes no type argument. A call writtenhasJourney<Job>(body)drops the<Job>: a typed envelope keeps its payload type through the guard without one, and a body typedunknownnarrows toContextEnvelope<unknown>, whosedatais yours to check. -
personal_data_in_public_valuehas a thirddetail.field. BesidesjourneyLabelanddisplayableAliasesit can beerrorMessage. A handler that switches on the field exhaustively needs the new case; one that logs it needs nothing. -
A type annotation on an injected payload. If you declared a value or a queue’s job payload as
ContextEnvelope<T>, annotate it asPayloadEnvelope<T>instead, which is whatinjectPayloadreturns and what the consumer can actually receive. Nothing changes at run time, andextractPayloadtakes either. To narrow one toContextEnvelope<T>, use the exportedhasJourneyguard. -
compose.published.yamlneedsFLIGHT_RECORDER_VERSION. It used to fall back tolatest. Export the release tag, such asFLIGHT_RECORDER_VERSION=v0.1.0, besideCOMPOSE_FILE. -
Empty journeys left by development builds. Before this release, an event refused with
event_id_conflictand a journey id that did not exist yet created that journey with no events, and it appeared in the journey list. A refused event now leaves no trace. Journeys left behind this way haveeventCount0; remove any you find withdelete:journey(OPERATIONS section 8). -
ADMIN_TOKENis trimmed now, asENCRYPTION_KEYalready was. Two consequences for a deployment whose token carries whitespace. A token of 31 characters padded to 32 is refused at startup, namingADMIN_TOKEN, where it used to be accepted: set a real 32-character token (openssl rand -hex 32). And a token with a space, a newline or a byte-order mark around it is now the trimmed value everywhere: existing web sessions stop verifying once the API and the web app are both on this release, which signs anyone in again at the login form, and a script that sends the admin token to the API has to send the trimmed value in itsauthorizationheader. Upgrade the two together, as the release expects: a stack running one of each would have the web app signing sessions the API refuses until it catches up. A token with no surrounding whitespace is unaffected. -
Rename SDK calls and options as in the table under Changed. Convert numeric SDK options before passing them:
maxBufferedEvents: Number(process.env.MAX_BUFFERED), not the string. -
Migration 020 adds a column to
journey_eventswith a five-secondlock_timeout, as 017 did; runmigrateagain if it gives up behind a long transaction (docs/OPERATIONS.mdsection 4). There is no backfill: events stored before it readaliases: null. -
Migration 021 adds four columns to
journeyswith the same five-secondlock_timeout(docs/OPERATIONS.mdsection 4). There is no backfill: a journey failed before it readsfailedStep: nulluntil its next failure; show itslastStepmeanwhile. -
Send
limitonce, as a whole number of at least 1. A client that sentlimit=0or an empty-looking value such aslimit=abcto get the default now gets400 invalid_query; leavelimitout instead.limit=1000still works and is read as 100. -
Drop unknown query keys. A client that sends keys
GET /v1/journeysdoes not read, or any query parameter to the ingestion routes other thandryRunon the batch route, gets400 invalid_query. A client that branched on theFST_ERR_*codes or the three removed protocol codes should branch on the HTTP status. -
Migration 015 rewrites every replay run row. It replaces each value in
replay_runs.request_headerswith[REDACTED]in one transaction (about 2 seconds for 100,000 runs, blocking updates to existing runs but not inserts, and doubling the table’s size until vacuum), including headers Flight Recorder set itself. Its down migration does nothing. A backup taken before it still holds destination credentials; rotate any that matter at the destination (docs/OPERATIONS.mdsection 4). Responses stored before the upgrade are not scrubbed. -
Migration 017 adds a column to
entity_aliases. It is a catalogue change on PostgreSQL 11 and later and finishes at once, but it gives up after five seconds if a long transaction holds the table. Runmigrateagain if it does (docs/OPERATIONS.mdsection 4). -
Migration 018 adds columns, a constraint and a trigger. The columns are nullable with no default, a catalogue change with no table rewrite; the constraint is added unvalidated and then validated in a second transaction that does not block writes. The trigger clears a masked alias’s plain-text copy, so the previous API can keep ingesting and rotating keys alongside the new one during a rolling upgrade. Each lock request gives up after five seconds, so in the worst case writes to
journeysstall for about ten seconds; runmigrateagain if it gives up. There is no backfill: journeys recorded before the upgrade show no label or last step until new events arrive, and older displayable aliases are not matched by text until an event states them again. -
Migration 019 builds two indexes concurrently. It does not block ingestion, but it waits for transactions that started before it, and gives up with
canceling statement due to lock timeoutafter 10 minutes if one (a long retention batch, an admin deletion, apg_dump) is still running. Runmigrateagain once it ends; the rerun drops what the interrupted build left and builds it afresh. Run it against PostgreSQL directly, not through a transaction-pooling PgBouncer.docs/OPERATIONS.mdsection 10 has a query that shows what the build is waiting for. -
The Helm migrate Job has a deadline,
migrations.activeDeadlineSeconds, 1800 seconds by default. Upgrade withhelm upgrade --timeout 30mto match, raise the value if your migrations need longer, or set it to null. -
Values written before key ids carry none. They read as before.
rotate:statuscounts them as legacy and exits 1 until they are rewritten. Runrotate:reencryptonce with onlyENCRYPTION_KEYset to upgrade them under the same key. API keys issued earlier showkey id not recorded yet; recorded on next useand do not hold the exit code at 1 unless a rotation is under way. -
There is no rolling back once
fr1.values are written. An earlier build cannot read them, and it would send replays without their destination headers. To roll back, restore the backup taken before upgrading. -
Keys are trimmed of surrounding whitespace. An
ENCRYPTION_KEYconfigured with surrounding whitespace, usually a trailing newline from a secrets file such as a Kubernetes secret created with--from-file, derives different keys after the upgrade, so earlier data stops decrypting and earlier API keys answer 401.ENCRYPTION_KEY_PREVIOUSis trimmed the same way. Check before upgrading:Terminal window kubectl get secret <name> -o jsonpath='{.data.ENCRYPTION_KEY}' | base64 -d | od -c | tail -2A
\nbefore the final offset means the key has one. -
Content hashes need no migration. Rows written before keyed hashes keep their unkeyed hash, and a resend is still compared against it, so a delivery that straddles the upgrade dedupes. Those rows remain an oracle for what they masked until they are deleted or retention removes them. After a key rotation completes, a duplicate delivery of an event recorded under the removed key is answered
409 event_id_conflict, which the SDK treats as permanent; the stored event is unaffected. Resending one event id from a fleet running SDK builds that render a value differently can also be answered 409. -
Rows are not rewritten by new redaction or masking. Payloads, error messages, stacks and webhook signature headers stored by an earlier build keep what they held, including any credential. Treat such rows as holding it, rotate what they hold, and remove them with
delete:journey,delete:identifierordelete:range(docs/OPERATIONS.mdsection 8, ADR-045). Logs kept from earlier builds may hold searched identifiers, and replay destination audit rows written earlier keep the base URL;docs/OPERATIONS.mdsections 8 and 13 say what to do about each. -
Redaction reaches further, so expect more
[REDACTED]. If a key name on the built-in list appears somewhere it is not a secret, scope it with a dotted path in your ownredactlist. -
A client that sends
error.stackstops having it stored unless the environment usesfull-payloadon an installation withALLOW_FULL_PAYLOAD_CAPTURE. -
Run
doctorafter upgrading. ItsJourney environmentscheck fails when an earlier build stored events across environments, anddocs/OPERATIONS.mdsection 12 lists them. -
REPLAY_ALLOWED_HOSTSdefaults tolocalhostincompose.published.yamland the Helm chart. An installation that replays tohost.docker.internalwithout setting the variable must now set it (REPLAY_ALLOWED_HOSTS=localhost,host.docker.internal, orapi.replayAllowedHosts); replays to it are otherwise refused withhost_not_allowed. -
An installation that used the bundled database should add
-f compose.bundled.yamlto keep the same behaviour. -
Shell exports no longer reach
infrastructure/compose.yaml. A stack that was configured withexport ENCRYPTION_KEY=…now starts on the published defaults instead. Move the values into the repository-root.env, and recreate the containers withdocker compose … up -d;docker compose restartdoes not re-readenv_file. -
Conformance cases are loaded in order of id, not of file name. A harness comparing with the manifest has to sort the same way.
Known limitations
Section titled “Known limitations”- The admin token is a single shared secret that reads every project, with no user accounts and no record of who used it.
- Propagated journey context is validated for shape but is not authenticated.
- Of the five verbs in the product promise, changed and rejected are demonstrated end to end. Duplication and loss are not yet first-class.
- The diff compares a step’s own input and output, and arrays by position (ADR-025, ADR-030). Replay targets development destinations only.
audit_eventsis never swept, and the login limiter is per process. docs/ROADMAP.md lists what else is known to be open.