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.
Logging
Section titled “Logging”EnsoLogLevelError
Section titled “EnsoLogLevelError”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.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new EnsoLogLevelError(
variable,value):EnsoLogLevelError
Defined in: core/src/log-process.ts:109
Parameters
Section titled “Parameters”variable
Section titled “variable”string
string
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
RetiredEnvironmentError
Section titled “RetiredEnvironmentError”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.
Extends
Section titled “Extends”Error
Constructors
Section titled “Constructors”Constructor
Section titled “Constructor”new RetiredEnvironmentError(
names):RetiredEnvironmentError
Defined in: core/src/log-process.ts:133
Parameters
Section titled “Parameters”readonly string[]
Returns
Section titled “Returns”Overrides
Section titled “Overrides”Error.constructor
EnsoLogLevels
Section titled “EnsoLogLevels”Defined in: core/src/log-process.ts:88
Properties
Section titled “Properties”console
Section titled “console”
readonlyconsole:"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.
readonlyfile:"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.
overrides
Section titled “overrides”
readonlyoverrides: readonlyEnsoLogOverride[]
Defined in: core/src/log-process.ts:93
EnsoLogOverride
Section titled “EnsoLogOverride”Defined in: core/src/log-process.ts:82
A per-category override: enso.host.events=trace turns one subtree up without the rest.
Properties
Section titled “Properties”category
Section titled “category”
readonlycategory: readonlystring[]
Defined in: core/src/log-process.ts:83
readonlylevel:"trace"|"debug"|"info"|"warning"|"error"|"fatal"
Defined in: core/src/log-process.ts:84
EnsoLogTail
Section titled “EnsoLogTail”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.
Properties
Section titled “Properties”
readonlyclose: () =>void
Defined in: core/src/log-process.ts:693
Returns
Section titled “Returns”void
readonlystep: () =>EnsoLogTailStep
Defined in: core/src/log-process.ts:692
Returns
Section titled “Returns”EnsoLogTailStep
Section titled “EnsoLogTailStep”Defined in: core/src/log-process.ts:666
What one step of a tail found.
Properties
Section titled “Properties”
readonlyday:string
Defined in: core/src/log-process.ts:670
The local day the file is named for: a change is the midnight rotation.
readonlylines: readonlyobject[]
Defined in: core/src/log-process.ts:674
The matching records, oldest first, capped to the newest maxRecords.
malformed
Section titled “malformed”
readonlymalformed: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.
matched
Section titled “matched”
readonlymatched:number
Defined in: core/src/log-process.ts:676
How many matched BEFORE the cap — matched > lines.length is “the last N of M”.
offset
Section titled “offset”
readonlyoffset:number
Defined in: core/src/log-process.ts:672
The byte the next step continues from, and what a reconnecting client passes as ?from=.
readonlytype:"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.
LogChunkDecoder
Section titled “LogChunkDecoder”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.
Properties
Section titled “Properties”carried
Section titled “carried”
readonlycarried: () =>number
Defined in: core/src/log-process.ts:635
Bytes held back — the partial line after the last newline seen.
Returns
Section titled “Returns”number
decode
Section titled “decode”
readonlydecode: (chunk) =>string
Defined in: core/src/log-process.ts:633
Parameters
Section titled “Parameters”Buffer
Returns
Section titled “Returns”string
ProcessLoggingOptions
Section titled “ProcessLoggingOptions”Defined in: core/src/log-process.ts:322
Properties
Section titled “Properties”levels?
Section titled “levels?”
readonlyoptionallevels?:EnsoLogLevels
Defined in: core/src/log-process.ts:328
From parseEnsoLogLevels; the defaults when omitted: file info, no console.
logsDir
Section titled “logsDir”
readonlylogsDir:string
Defined in: core/src/log-process.ts:324
ensoLogsDir(repoRoot) — created if absent.
process
Section titled “process”
readonlyprocess: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.
secretValues?
Section titled “secretValues?”
readonlyoptionalsecretValues?: readonlystring[] | (() => readonlystring[])
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.
DEFAULT_FILE_LEVEL
Section titled “DEFAULT_FILE_LEVEL”
constDEFAULT_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()
Section titled “configureProcessLogging()”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.
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”Promise<() => Promise<void>>
effectiveFileLevel()
Section titled “effectiveFileLevel()”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.
Parameters
Section titled “Parameters”levels
Section titled “levels”category
Section titled “category”readonly string[]
Returns
Section titled “Returns”"trace" | "debug" | "info" | "warning" | "error" | "fatal"
ensoLogDay()
Section titled “ensoLogDay()”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.
Parameters
Section titled “Parameters”filename
Section titled “filename”string
Returns
Section titled “Returns”string | undefined
ensoLogFilename()
Section titled “ensoLogFilename()”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.
Parameters
Section titled “Parameters”Date
Returns
Section titled “Returns”string
ensoLogLineRenderer()
Section titled “ensoLogLineRenderer()”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.
Parameters
Section titled “Parameters”options?
Section titled “options?”colors?
Section titled “colors?”boolean
wordWrap?
Section titled “wordWrap?”number | boolean
Returns
Section titled “Returns”(line) => string
installLoggingShutdown()
Section titled “installLoggingShutdown()”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.
Parameters
Section titled “Parameters”dispose
Section titled “dispose”() => Promise<void>
Returns
Section titled “Returns”void
isLoggingConfigured()
Section titled “isLoggingConfigured()”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.
Returns
Section titled “Returns”boolean
logChunkDecoder()
Section titled “logChunkDecoder()”logChunkDecoder():
LogChunkDecoder
Defined in: core/src/log-process.ts:639
Returns
Section titled “Returns”logRecordOf()
Section titled “logRecordOf()”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.
Parameters
Section titled “Parameters”@timestamp
Section titled “@timestamp”string = ...
"TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL" = ...
logger
Section titled “logger”string = ...
message?
Section titled “message?”string = ...
properties?
Section titled “properties?”Record<string, unknown> = ...
Returns
Section titled “Returns”LogRecord
logsDirFrom()
Section titled “logsDirFrom()”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.
Parameters
Section titled “Parameters”environment
Section titled “environment”Readonly<Record<string, string | undefined>>
repoRoot
Section titled “repoRoot”string
Returns
Section titled “Returns”string
openLogTail()
Section titled “openLogTail()”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.
Parameters
Section titled “Parameters”options
Section titled “options”filter
Section titled “filter”{ 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.
from.day
Section titled “from.day”string
from.offset
Section titled “from.offset”number
logsDir
Section titled “logsDir”string
maxRecords?
Section titled “maxRecords?”number
Defaults to LOG_TAIL_MAX_RECORDS.
() => Date
The clock the day’s filename is computed from; a test moves it across midnight.
Returns
Section titled “Returns”parseEnsoLogLevels()
Section titled “parseEnsoLogLevels()”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.
Parameters
Section titled “Parameters”environment
Section titled “environment”Readonly<Record<string, string | undefined>>
defaults
Section titled “defaults”console
Section titled “console”"trace" | "debug" | "info" | "warning" | "error" | "fatal" | undefined
Returns
Section titled “Returns”parseEnsoLogLine()
Section titled “parseEnsoLogLine()”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.
Parameters
Section titled “Parameters”string
Returns
Section titled “Returns”string | { @timestamp: string; level: "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL"; logger: string; message?: string; properties?: Record<string, unknown>; }
retiredEnvironmentNames()
Section titled “retiredEnvironmentNames()”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.
Parameters
Section titled “Parameters”environment
Section titled “environment”Readonly<Record<string, string | undefined>>
Returns
Section titled “Returns”string[]