Skip to content

core/src/log-process

A process’s sink (#132 item 2): ONE log for the harness — .enso/logs/enso-YYYY-MM-DD.jsonl, every process appending to the same day’s file, every record redacted by field name before it is written, and — for a terminal — a pretty console sink beside it at its own level.

And THE KNOB: ENSO_LOG_LEVEL picks the tier the file holds, per subsystem if wanted; ENSO_LOG_CONSOLE the terminal’s.

Called by a LAUNCHER (dev.ts, dev-web.ts, the harness extension when pi is a child), never by startEnsoServer: a library configures nothing, so a test that starts the server sees no output it did not ask for, and a test that wants the records installs @logtape/testing’s recorder in its own configure.

⚠ Imported as @enso/core/log-process, NEVER from the barrel. @logtape/file builds its sinks at module evaluation (createFileSinks() on node:fs), so it is not tree-shaken the way paths.ts’s unused node:fs is — exported from index.ts it took the page down (ReferenceError: import_node_fs4 is not defined, the slice-2 receipt). The barrel stays browser-safe; this module is for processes with a filesystem.

⚠ One file, several processes — so the file is named by DATE, never rotated by size. The sink opens with O_APPEND and (below) writes each record in one writeSync, which is safe for any number of writers: every line lands whole at the end (measured: two processes, 400 records each, 800 lines, none torn). A size-rotated file is not: rotation RENAMES the file under the other process, whose descriptor then points at the rotated copy and keeps writing there. Date-named files are never renamed; each process computes the same name for the same day. process is on every record so the writer is still known, and sequence — a per-process count — orders two of one process’s records inside one millisecond. Old days are deleted by age.

⚠ Blocking and UNBUFFERED, on purpose — measured, not assumed (the first live receipt). LogTape’s blocking file sink has no flush timer: flushInterval belongs to the non-blocking sink alone, and the buffered blocking sink flushes only when the NEXT record arrives or on dispose. A record over 200 bytes — every one of ours with properties — sat in the buffer until something else was logged; the web launcher’s file stayed empty for minutes after its banner. For a debugging log that is the wrong property exactly when it matters: the last record before a hang is the one you need, and it would be the one not on disk. So bufferSize: 0 — each record is one writeSync + flushSync when the call returns. Our volume is tens of records per run at info, hundreds at trace; the syscall pair is the price of the file being the record.

Why not nonBlocking: its failures are reported on the meta logger too (2.3.4, contrary to its doc), but a background flush can lose the tail on a crash, and a synchronous writeSync that throws is caught by LogTape and reported on ["logtape", "meta"] at fatal, bypassing the sink that failed — routed to the console here, so a full disk is seen, not silent.

Defined in: core/src/log-process.ts:108

A knob value that is not a level. Thrown at startup, naming the value — never a silent default.

  • Error

new EnsoLogLevelError(variable, value): EnsoLogLevelError

Defined in: core/src/log-process.ts:109

string

string

EnsoLogLevelError

Error.constructor


Defined in: core/src/log-process.ts:132

A variable from before the rename, still set. Thrown at startup, naming every one of them.

⚠ A RENAMED KNOB THAT NOBODY READS IS SILENT, AND SILENCE IS THE WORST OUTCOME HERE. Every ZEN_* variable became ENSO_*; a shell profile or a CI job still exporting the old spelling would simply get the default — a session logging at info when its author asked for trace, and nothing anywhere saying why. So a stale variable is a REFUSAL, not a shrug.

Thrown from parseEnsoLogLevels, which every process that configures logging calls with its own environment — the launchers, the server, the pi extension — so there is one check and no process that skips it.

  • Error

new RetiredEnvironmentError(names): RetiredEnvironmentError

Defined in: core/src/log-process.ts:133

readonly string[]

RetiredEnvironmentError

Error.constructor


Defined in: core/src/log-process.ts:88

readonly console: "trace" | "debug" | "info" | "warning" | "error" | "fatal" | undefined

Defined in: core/src/log-process.ts:92

The terminal’s; undefined for no console sink. Global — overrides are for the file.

readonly file: "trace" | "debug" | "info" | "warning" | "error" | "fatal"

Defined in: core/src/log-process.ts:90

The file’s lowest level for everything under enso not named by an override.

readonly overrides: readonly EnsoLogOverride[]

Defined in: core/src/log-process.ts:93


Defined in: core/src/log-process.ts:82

A per-category override: enso.host.events=trace turns one subtree up without the rest.

readonly category: readonly string[]

Defined in: core/src/log-process.ts:83

readonly level: "trace" | "debug" | "info" | "warning" | "error" | "fatal"

Defined in: core/src/log-process.ts:84


Defined in: core/src/log-process.ts:691

An open tail: one step per poll, and the close that releases the descriptor.

⚠ close is not optional housekeeping. One descriptor is held for the life of the tail so a poll is a statSync and a readSync rather than an open per tick; a caller that drops the handle instead of closing it leaks one descriptor per connection, which on a page the operator leaves open and reloads is the kind of leak that surfaces as EMFILE hours later.

readonly close: () => void

Defined in: core/src/log-process.ts:693

void

readonly step: () => EnsoLogTailStep

Defined in: core/src/log-process.ts:692

EnsoLogTailStep


Defined in: core/src/log-process.ts:666

What one step of a tail found.

readonly day: string

Defined in: core/src/log-process.ts:670

The local day the file is named for: a change is the midnight rotation.

readonly lines: readonly object[]

Defined in: core/src/log-process.ts:674

The matching records, oldest first, capped to the newest maxRecords.

readonly malformed: number

Defined in: core/src/log-process.ts:678

Lines that were not records at all. A count, not the text: enough to send a reader to the file.

readonly matched: number

Defined in: core/src/log-process.ts:676

How many matched BEFORE the cap — matched > lines.length is “the last N of M”.

readonly offset: number

Defined in: core/src/log-process.ts:672

The byte the next step continues from, and what a reconnecting client passes as ?from=.

readonly type: "backlog" | "records"

Defined in: core/src/log-process.ts:668

backlog — this is the file’s whole view; records — these landed since the last step.


Defined in: core/src/log-process.ts:632

The decoder a poll-based reader needs. Two different things straddle a poll boundary and both are the same carry: BYTES — a multibyte character cut in half by the byte range decodes to U+FFFD unless the incomplete sequence waits for the next chunk (a 🚀 split across a poll printed launch ??? done) — and LINES, where a partial last record waits for its newline.

⚠ The carry is BYTES, not text, and the split is at the last 0x0A (PR #336 review). A newline never occurs inside a multibyte UTF-8 sequence, so cutting at one never cuts a character — which is why one carry serves both cases — and, more to the point, the carry’s LENGTH is then a byte count a reader can subtract from its file offset. The tail publishes offset − carried() as the byte a reconnect resumes from; publishing the raw read offset put a reconnecting page mid-record, and the record’s tail parsed as malformed and was lost.

decode returns the complete lines in this chunk, newline-separated and without a trailing one, or the empty string when nothing completed.

readonly carried: () => number

Defined in: core/src/log-process.ts:635

Bytes held back — the partial line after the last newline seen.

number

readonly decode: (chunk) => string

Defined in: core/src/log-process.ts:633

Buffer

string


Defined in: core/src/log-process.ts:322

readonly optional levels?: EnsoLogLevels

Defined in: core/src/log-process.ts:328

From parseEnsoLogLevels; the defaults when omitted: file info, no console.

readonly logsDir: string

Defined in: core/src/log-process.ts:324

ensoLogsDir(repoRoot) — created if absent.

readonly process: string

Defined in: core/src/log-process.ts:326

Which launcher this is — server, web, pi — on every record it writes, since the file is shared.

readonly optional secretValues?: readonly string[] | (() => readonly string[])

Defined in: core/src/log-process.ts:338

The secret values to scrub wherever they appear, from knownSecretValues (#139). Omitted — a test, a process with no secret store — means the value pass does not run.

A THUNK — knownSecretValuesTracking(repoRoot) — is what a launcher passes (#207): it is asked per record, so a key rotated in .enso/secrets.env while the process runs is redacted from the next record on, matching the reader, which was always per call. An array is a snapshot of the set at configure time and stays one.


const DEFAULT_FILE_LEVEL: LogLevel = 'info'

Defined in: core/src/log-process.ts:101

The tier every process starts in: the spine, and what went wrong.


configureProcessLogging(options): Promise<() => Promise<void>>

Defined in: core/src/log-process.ts:436

Configure the process; resolves to the disposer that flushes and closes the file.

ProcessLoggingOptions

Promise<() => Promise<void>>


effectiveFileLevel(levels, category): "trace" | "debug" | "info" | "warning" | "error" | "fatal"

Defined in: core/src/log-process.ts:211

The level a record’s category must reach to be written: the longest override prefix that matches, else the file’s level.

["enso","host","events"] is matched by an override on enso.host.events, then enso.host, then enso.

EnsoLogLevels

readonly string[]

"trace" | "debug" | "info" | "warning" | "error" | "fatal"


ensoLogDay(filename): string | undefined

Defined in: core/src/log-process.ts:378

The local day a log file is for — 2026-09-23 from enso-2026-09-23.jsonl — or undefined for any other name in the directory. The inverse of ensoLogFilename.

string

string | undefined


ensoLogFilename(date): string

Defined in: core/src/log-process.ts:364

enso-2026-09-09.jsonl: the harness’s one log for that day — the machine’s LOCAL day, not UTC.

The file is for the person at the machine: at 19:05 local bun run logs must not say “no log for today” because it is 00:05 tomorrow in Greenwich (found live). The timestamps inside stay ISO UTC.

Date

string


ensoLogLineRenderer(options?): (line) => string

Defined in: core/src/log-process.ts:598

The terminal’s formatter, for the file’s records: what bun run logs prints.

wordWrap as the pretty formatter takes it — true wraps to the terminal’s width, false keeps each record’s lines whole (for a pipe), a number is a width. colors off for a pipe.

boolean

number | boolean

(line) => string


installLoggingShutdown(dispose): void

Defined in: core/src/log-process.ts:531

The stop contract every launcher owes a supervisor (#267): flush the file, then exit the way the signal would have — 128 + the signal's number, so 130 for SIGINT and 143 for SIGTERM.

128 + n is read by a hub, a shell and systemd alike, which makes the table a CONTRACT and not an implementation detail; it lived twice, verbatim, in the two dev launchers, where a third signal or a changed flush order would have reached one of them.

once, not on: a second signal during the flush must not start a second dispose.

() => Promise<void>

void


isLoggingConfigured(): boolean

Defined in: core/src/log-process.ts:427

True once some configurator has run in this process — pi in-process shares the server’s.

boolean


logChunkDecoder(): LogChunkDecoder

Defined in: core/src/log-process.ts:639

LogChunkDecoder


logRecordOf(line): LogRecord

Defined in: core/src/log-process.ts:579

A line of the file, back into the record it was: the category split, the level and the instant read back, and the message re-joined from its template and properties by logLineMessageParts — the grammar the browser shares — so any LogTape formatter renders it exactly as the console did when it was written.

string = ...

"TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL" = ...

string = ...

string = ...

Record<string, unknown> = ...

LogRecord


logsDirFrom(environment, repoRoot): string

Defined in: core/src/log-process.ts:349

Where this process writes: ENSO_LOGS_DIR when set — a user keeping logs elsewhere, a test keeping them out of the checkout — else .enso/logs under the repo (ensoLogsDir).

One rule for every launcher and for the extension, so they land in the same file.

Readonly<Record<string, string | undefined>>

string

string


openLogTail(options): EnsoLogTail

Defined in: core/src/log-process.ts:730

Open a tail over the day’s file; the returned step reads whatever has appeared since the last call, filters it, and caps it.

⚠ type is not a flag anyone sets: A STEP THAT BEGAN AT BYTE 0 IS THE FILE’S WHOLE VIEW, and any other step is an append. That one rule covers the first step with no from, the local-midnight rotation onto a new filename, a file truncated in place, and — with one check the rule alone did not give (PR #336 review) — a file REPLACED under the same name: stat is by path and read is by descriptor, so an unlink-and-recreate that grew past the old offset read zero bytes from the old inode forever. The inode is compared on every step, and a change is a restart from the head.

⚠ TWO SHAPES ARE NOT COVERED, and are stated rather than claimed (PR #336 review, round two). A file truncated and regrown past the old offset WITHIN ONE POLL — the > file or cp shape — keeps its inode and its size, so nothing here fires and the read starts mid-content of the rewritten file, which the reader sees as one malformed line and the records before it missing. And a from carries the day it belongs to but not the inode, so a reconnect after an unlink-and-recreate resumes at a byte of the OLD file applied to the new one. Closing either needs the token to describe the file’s content, not its position; until then, bun run logs reads the day whole and is the reader that cannot be fooled.

⚠ ONE stat, never exists then stat: two calls are a window, and a file removed inside it (the 14-day prune, an rm) made the second throw from a timer with nothing above it.

⚠ The CLI’s --follow starts at the END of the file (it has already printed the day) and this starts at its HEAD, because a page that opens with nothing on it is not a log viewer. That is the from parameter, not a second reader.

The first step therefore reads the whole day into memory once, which is what GET /api/logs/bundle already does for the same file; the cap bounds what is handed on, not what is read.

EnsoLogFilter

{ day: string; offset: number; }

Resume here instead of the day’s head — the offset a previous frame carried, and the day that offset is a position in. The first step compares that day to the file it resolves and drops the offset on a mismatch: the route validated the day against one now(), this picks the file with another, and a midnight in between is the head of a day skipped.

string

number

string

number

Defaults to LOG_TAIL_MAX_RECORDS.

() => Date

The clock the day’s filename is computed from; a test moves it across midnight.

EnsoLogTail


parseEnsoLogLevels(environment, defaults): EnsoLogLevels

Defined in: core/src/log-process.ts:162

The knobs, from the environment a launcher was started with: ENSO_LOG_LEVEL=“info” — the file’s tier (default info) ENSO_LOG_LEVEL=“info,enso.host.events=trace” — plus one subtree turned up ENSO_LOG_CONSOLE=“warning” — the terminal’s (the launcher’s default otherwise) warn is accepted for warning, since that is what EnsoLogger calls it.

Readonly<Record<string, string | undefined>>

"trace" | "debug" | "info" | "warning" | "error" | "fatal" | undefined

EnsoLogLevels


parseEnsoLogLine(raw): string | { @timestamp: string; level: "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"; logger: string; message?: string; properties?: Record<string, unknown>; }

Defined in: core/src/log-process.ts:561

One raw line of the file → the record, or the reason it is not one.

THE parser (the reader, the bundle route, and the bundle reader all go through it): a line that is not a record is said, never skipped silently — the file is the honest place.

string

string | { @timestamp: string; level: "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"; logger: string; message?: string; properties?: Record<string, unknown>; }


retiredEnvironmentNames(environment): string[]

Defined in: core/src/log-process.ts:147

Every ZEN_* name the given environment still carries, in the order a reader would fix them.

Readonly<Record<string, string | undefined>>

string[]