Skip to content

Rebind captured component, conditional, and tagged loggers on initLogSettings(true) - #3239

Merged
kriszyp merged 4 commits into
mainfrom
fix/rebind-captured-component-loggers
Oct 17, 2026
Merged

kriszyp merged 4 commits into
mainfrom
fix/rebind-captured-component-loggers

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

When Harper starts without an existing install, harper_logger falls back to stdout before config exists ([level] [init]) while modules finish loading. After installer.ts writes harperdb-config.yaml and calls initLogSettings(true), mainLogger was replaced with a new HarperLogger instance. References from forComponent(...), loggerWithTag(...), and utility/logging/logger.ts captured at import time stayed bound to the dead fallback logger.

🤔 Is rebinding captured component and tagged loggers across initLogSettings(true) warranted?

Yes, as specified. Modules imported before first-boot install completes (mqtt, security/user, certificateVerification, derivedIndexes) cache their logger handles at module scope. Replacing mainLogger without updating those handles strands their logs on stdout for the life of the process.

💡 Solution

🤔 Should conditional tagged loggers return null when the log level is raised at runtime?

Yes. logger.withTag(tag) and loggerWithTag(tag, true) check the level through getters on each access. They return null when a level is disabled, matching logger.conditional. Callers use optional chaining (?.). Keeping methods callable and filtering inside the function would still evaluate call arguments when disabled.

🤔 Should string logging: <level> values normalize to { level }?

Yes. normalizeLogOptions converts string values (logging: debug) to { level: 'debug' } for root, logging.external, and component sections. It ignores non-string scalars. Boot validation still requires an object at root. Normalizing string shorthand in component sections and hot reload prevents silent no-ops, and is easy to tighten if needed.

⚖️ Alternatives

  • Mutate the existing mainLogger in place inside initLogSettings(true) instead of constructing a new mainLogger and handing off components/conditional — viable. But handing components and conditional into createLogger keeps mainLogger construction uniform and still reconfigures all captured component and conditional handles in place. Tagged loggers need getter dispatch either way.
  • Store every withTag object in a registry and mutate its properties on config change — rejected because callers invoke withTag per request or per event, which would retain those closures for the life of the process.

🔧 Changes

✅ Verification

  • Ran npx mocha unitTests/utility/logging/harper_logger.test.js (149 passing, fails on base where captured component/tagged loggers in harper_logger.test.js and firstBootComponentLogging.cjs stay on [init] stdout).
  • Ran npx mocha unitTests/server/mqtt.test.js (16 passing) and npm run lint && npm run build.

🤖 Generated by Google Gemini; posted via @kriszyp.

Release target: main — fixes first-boot logger rebinding across initLogSettings(true) with no backport milestone specified
Related PRs: #372 overlaps (touches mainLogger.components and tagged logger methods in harper_logger.ts), #3173 overlaps (tests updateLogger rotation inheritance in harper_logger.test.js)
Complexity: medium

Deferred findings

  • 88a15e8c031d36a5 nit reliability — utility/logging/harper_logger.ts — applyLogSettings per-component loop not isolated; a non-string component path/root still throws from the async change listener and skips lat — NOT FILED
  • ee81f05a12384ecc nit correctness — utility/logging/harper_logger.ts — conditional tagged loggers return null after a runtime level raise; unguarded logger.withTag() calls now throw — NOT FILED
  • 0842d100046ffe2f nit performance — utility/logging/harper_logger.ts — tagged conditional loggers run a level-check getter on every method access — NOT FILED
Origin — the dispatch brief this PR was written from

Component loggers captured at module load do not survive the install-time logger re-init, so in a process that installs AND runs Harper (first-boot harper run on a fresh root, e.g. a container entrypoint: bin/run.ts ~101-104 calls install() inside run) their messages reach no sink. Example: security/keys.ts:25 const logger = forComponent('tls').conditional runs at import under the no-config fallback logger; installer.ts ~211 initLogSettings(true) builds a NEW mainLogger with a new components Map (utility/logging/harper_logger.ts ~561, components map ~821-841) and nothing rebinds already handed-out component loggers. Result: Reloading certificate, Waiting for matching TLS certificate and private key, Error loading private key and similar lines vanish on first boot, and appear after one restart.

Acceptance

Test: in one process, capture a component logger before the configured initLogSettings(true), then log through it after; the line lands in the configured log file. Integration or unit coverage that a first-boot install+run process logs a keys.ts tls message. Shown failing on origin/main. No change to log formats.

Dispatch: task harper-logger-captured-component-rebind · queued by finding-triage/wave-35 · ran by gemini/gemini-4-argon-high/medium · worker kzyp-xps-1

Review-Coverage: authored=gemini; ran=claude; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse,muse; rounds=4; full=2 @ 0b037b7

Review-Attention: study ~20m (critical: derivedIndexRuntime.ts, derivedIndexes.ts +1; hot: mqtt.ts; decisions: tagged-conditional-null-on-raise, scalar-logging-shorthand, component-error-recovery, live-stdstreams, do-less-alternative) @ 0b037b7

kriszyp and others added 4 commits October 16, 2026 09:07
…Settings(true)

When harper_logger.ts loads before installHdb creates harperdb-config.yaml,
initLogSettings() falls back to an unconfigured stderr-only mainLogger at
level error. Modules imported during that window (such as security/keys.ts)
capture component loggers (forComponent('tls').conditional) in module-scope
bindings. When installer.ts subsequently calls initLogSettings(true),
rebuilding mainLogger with a fresh components Map left those earlier
component and tagged loggers pointing at the stale fallback logger.

Carry the existing components Map and top-level taggedLoggers list into
the replacement mainLogger during initLogSettings(true), re-apply the
configured path, level, stdStreams, and rotation policy to every already-
registered component logger, and refresh loggerWithTag closures whenever
their backing logger updates.

Dispatch-Task: harper-logger-captured-component-rebind
Co-Authored-By: Gemini <noreply@google.com>
- Replace taggedLoggers callback list with zero-retention level getters and dynamic mainLogger lookup in loggerWithTag
- Inherit mainLoggerRef?.logToStdstreams in updateLogger when component logOptions.stdStreams is unset
- Use updateLogger across both configured and fallback initLogSettings loops and pass per-component logging config
- Clear path-keyed rotation entry in logger.path setter when path is set to falsy after an authoritative rotation clear
- Preserve mainLogger.conditional across initLogSettings and export it from utility/logging/logger.ts
- Guard conditional logger.error/warn calls with optional chaining in derivedIndexRuntime, derivedIndexes, hnswDerivedIndex, and mqtt
- Remove redundant refreshExternalLogger helper from logConfigFixture.js and harden harper_logger.test.js

Dispatch-Task: harper-logger-captured-component-rebind
Co-Authored-By: Gemini <noreply@google.com>
- Prevent scalar component.logging or logging.external values from reaching updateLogger and throwing TypeError on logOptions.root assignment in applyLogSettings and initLogSettings
- Add test coverage in harper_logger.test.js

Dispatch-Task: harper-logger-captured-component-rebind
Co-Authored-By: Gemini <noreply@google.com>
…rrors

- Normalize string logging blocks (root logging, logging.external, and per-component logging) to { level } via normalizeLogOptions in both initLogSettings and applyLogSettings, while ignoring non-string scalars.
- Isolate per-component updateLogger calls in initLogSettings with try/catch so a malformed component path/root cannot reset the main logger to stdout fallback.
- Reset logLevel before getLogConfig on forceInit so a path.join throw on logging.root preserves the configured logLevel.
- Relax subprocess stderr assertion in the no-ambient-install test to check only for [tls] leakage.

Dispatch-Task: harper-logger-captured-component-rebind
Co-Authored-By: Gemini <noreply@google.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the logging system to support dynamic rebinding of component and tagged loggers, introduces a conditional property on loggers to dynamically resolve log levels via getters, and adds optional chaining to logger calls across various modules. Feedback on these changes identifies three key issues: first, reassigning externalLogger directly breaks CommonJS imports, which can be resolved using a Proxy; second, updating the main logger in stdout mode prints an unnecessary 'No path for logger' error to the console; and third, normalizeLogOptions fails to handle boolean values (like logging: false), causing disabled logging configurations to fall back to enabled states.

conditional: previousMainLogger?.conditional,
});
// setup the external logger
externalLogger = mainLogger.forComponent('external');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Reassigning externalLogger directly breaks references for any modules that imported externalLogger at load time, because CommonJS module.exports snapshots the reference to the initial wrapper object. Any subsequent property accesses (like path, level, etc.) or updates on the imported externalLogger will be lost or fail silently because they are performed on the stale wrapper object rather than the newly assigned component logger.

To solve this robustly, consider defining externalLogger as a const Proxy that dynamically forwards all operations (get, set, etc.) to an internal activeExternalLogger variable. Then, instead of reassigning externalLogger, you can simply update activeExternalLogger to point to the new component logger.

@kriszyp kriszyp Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mainLogger inherits previousMainLogger?.components (line 583 / 644), which already contains the 'external' entry created at module load. mainLogger.forComponent('external') returns that exact existing HarperLogger instance (externalLogger === mainLogger.forComponent('external')), and the loop at lines 589–600 / 649–651 reconfigures it in place via updateLogger. Callers that imported harperLogger.externalLogger at load time continue to reference the same object.

Comment on lines 165 to 167
if (path) logger.path = path;
else if (!mainLoggerRef?.path && logger !== mainLoggerRef) logger.path = undefined;
else console.error('No path for logger', logOptions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When applyLogSettings is called to disable file logging or when running in stdout/no-file mode, path is undefined. Since logger === mainLoggerRef (i.e., we are updating the main logger), the else if condition is skipped, and it prints No path for logger to console.error. This pollutes the console with error messages during normal operation.

Simplifying this to just clear the path when it is undefined avoids the noisy error.

	if (path) logger.path = path;
	else logger.path = undefined;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When mainLoggerRef has a path, path defaults to mainLoggerRef.path at line 140, so else logger.path = undefined would not clear mainLogger.path anyway and would remove the existing No path for logger diagnostic completely. The else if (!mainLoggerRef?.path && logger !== mainLoggerRef) branch specifically handles component loggers resetting back to the no-file fallback when mainLogger itself has no path.

Comment on lines +217 to +221
function normalizeLogOptions(options: any) {
if (typeof options === 'string') return { level: options };
if (options && typeof options === 'object') return options;
return undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If a component or external logger is configured with logging: false (or external: false), normalizeLogOptions will return undefined because typeof false is 'boolean'. This causes the configuration to fall back to the main logOptions or {} (default options), effectively enabling logging instead of disabling it.

Consider explicitly handling boolean values (or false specifically) in normalizeLogOptions to prevent them from falling back to enabled states.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateLogger expects an options object ({ level, path, root, stdStreams, rotation, tag }) and has no concept of boolean false disabling a logger — on main, a boolean logging: false threw TypeError: Cannot create property 'root' on boolean 'false' at logOptions.root = .... Returning undefined for non-string scalars falls back to inherited defaults instead of crashing.

@kriszyp
kriszyp marked this pull request as ready for review October 17, 2026 03:15
@kriszyp
kriszyp merged commit c4e8a34 into main Oct 17, 2026
90 of 92 checks passed
@kriszyp
kriszyp deleted the fix/rebind-captured-component-loggers branch October 17, 2026 03:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant