Every phase emits structured Pino records. Each record carries an event field (kebab-case dotted scope) plus phase-specific fields. All fields go through PiiRedactor before write.
<phase>.<action>.<outcome> — e.g. balance-resolve.fetch.start
correlationId
string
randomUUID() per phase invocation — correlates .start / .success / .failure records
module
string
Module/logger name — kebab-cased from import.meta.url basename (e.g. balance-resolve-actions) or an explicit name passed to getDebugByName. NOT the phase name.
bankAccountTail4
string
***NNNN — last-4 of a bankAccountUniqueId, never the full id
executeNavigateToBank failed — full transport-layer envelope, see INIT navigation forensics. Naming exception: predates the phase.action.outcome lowercase contract; kept UPPER-KEBAB so existing log dashboards and grep aliases continue matching while the forensics envelope stabilises.
One credential field resolved via SelectorResolver
login.submit
info
Submit button clicked
login.result.invalid_password
warn
Bank returned credentials-wrong
login.result.success
info
Post-submit page recognised as authenticated
login.completion.attempt
debug
One settle-poll capture — attempt / of + formPresent / advanced / hasError (see below)
login.completion
debug
Final completion snapshot composed at LOGIN.final — settled / attempts / waitedMs + form signals (see below)
login.completion.error
debug
A completion probe threw; snapshot stays neutral
LOGIN completion observer (enforced when opted in)¶
At LOGIN.final an observer composes four LOGIN-LOCAL signals — is the filled login form still present, is a loading spinner still visible, is an error banner present, and has the page advanced past the login URL — into one snapshot and logs it as login.completion. The verdict is neutral by default: a bank that has not opted into a settle budget always succeeds (the single-shot poll settles on the first capture), so behaviour is byte-identical for it. A bank that opts in (see loginCompletionPoll below) instead fails LOGIN.final non-retryably with LOGIN_NOT_COMPLETED when the poll budget is exhausted while the filled form is still on screen — surfacing, per bank in the CI logs, the case where a login lingers on a spinning form yet still passes the lenient cookie gate.
The composer is wrapped in a config-driven, FORM-FIRST poll. A phase has settled when it advanced past the start screen, an error surfaced, OR the filled form is gone. By default the poll is single-shot (one capture, never sleeps) so it is byte-identical to a lone snapshot and adds zero wall-time. A bank opts into a multi-attempt budget through the optional loginCompletionPoll config field; a perpetually spinning login (form still present, no error, URL unchanged) then exhausts the budget instead of passing silently.
enforceLoginCompletion(input) — the entry facade wired into LOGIN.final. It runs the existing LOGIN post-gates first; if those already fail it returns a neutral success and emits nothing. It reads input.config.loginCompletionPoll to resolve the poll budget (absent ⇒ single-shot) and returns the LOGIN.final verdict — a non-retryable LOGIN_NOT_COMPLETED fail only for an opted-in, unsettled poll; otherwise success.
Each poll capture emits one login.completion.attempt debug line (attempt, of, plus the three form signals formPresent / advanced / hasError); the final summary is the single login.completion line. Both drop the unreliable spinnerVisible from their logged projection.
captureCompletionSignals(ports) — the phase-agnostic composer (under Mediator/Completion). It reads an ICompletionPorts contract and returns an ICompletionSignals snapshot (formPresent, spinnerVisible, hasError, advanced).
pollCompletion(ports, opts) — the phase-agnostic poll that repeatedly calls the composer until the phase settles or the attempt budget is spent. It returns an ICompletionPollOutcome (settled, attempts, waitedMs, last). Sleep is injected so timing is deterministic in tests.
ICompletionPollOptions — the injected budget: intervalMs, maxAttempts, and the sleep function. ICompletionPollOutcome — the poll result.
LOGIN_COMPLETION_POLL_INTERVAL_MS (5000) and LOGIN_COMPLETION_POLL_MAX_ATTEMPTS (15) — the opt-in budget constants a bank references when it sets loginCompletionPoll.
buildLoginCompletionPorts(...) — the LOGIN adapter that binds that contract to login-local probes: the form-present probe re-counts the already-discovered password-field target (count-only, no new CSS selector), the spinner probe is buildIsLoadingVisible (the phase-neutral loading well-known), the error probe reuses the existing frame scan, and the advanced probe reuses the existing login-URL helper. It never probes dashboard state — that REVEAL belongs to AUTH-DISCOVERY.
# Watch the live logtail-fpipeline.log|jq.
# Find all BALANCE-RESOLVE events for a rungrep"balance-resolve"pipeline.log|jq.
# Find every fetch quarantinejq-c'select(.event == "balance-resolve.fetch.failure")'pipeline.log
Every Pipeline module obtains a Pino child logger derived from its file path so each event record carries an accurate module field without hand-maintained string constants:
getDebug(import.meta.url) — the default form, used by every Pipeline module. The basename of the URL (e.g. BalanceResolveActions.ts) is kebab-cased into the logger name (balance-resolve-actions).
getDebugByName(name) — the explicit-name escape hatch for cases where the logger name has to be dynamic at construction time (notably BaseScraper keying loggers by companyId). The legacy Common/Debug.js shim re-exports this as its getDebug so historic string-keyed callers keep working without churn.
Both helpers route through the same lazy-resolved root logger, so PiiRedactor always intercepts before any transport writes — see PII redaction for the censor pipeline.
The pipeline's logger primitives live in src/Scrapers/Pipeline/Logging/ (extracted from the legacy Types/Debug.ts blob during Phase 12c). Each file owns one concern so the moving parts are independently testable:
Logging/Debug.ts — public facade. Exports getDebug, getDebugByName, and the ScraperLogger type alias used as the return-type shorthand for pino's Logger across the pipeline.
Logging/LoggerNaming.ts — pure deriveLogName(import.meta.url) transform that strips the URL down to a kebab-cased module name; the return type is branded as LoggerNameKebab so consumers can't pass a raw string back into a slot that expects an already-derived name.
Logging/BankContext.ts — runWithBankContext(bank, fn) opens an async-local scope; getBankMixin is the pino mixin callback that merges that scope plus the active phase / stage / runId onto every log line; getActiveLogContext is a read-only accessor over the same record so tests can assert the mixin contract directly without depending on transport-flush timing.
Logging/RootLogger.ts — getRootLogger builds (or returns the cached) pino root the first time any child logger is read; the companion isRootLoggerCached predicate lets the deferred-resolve proxy decide whether the resolved child can be memoised yet or has to keep resolving fresh until setActiveBank lands.
Logging/ChildLoggerProxy.ts — buildDeferredLogger(name) returns the lazy-resolve Proxy that backs both getDebug and getDebugByName. The internal IProxyHandler type documents the exact get-trap shape so the cluster's unit tests can assert property-access semantics without re-deriving it from Proxy<T>.