Refactor: deduplicate the winston logger into one level-gated implementation with one process-wide default level - #432
Open
AmaadMartin wants to merge 3 commits into
Open
Conversation
added 3 commits
July 31, 2026 19:27
core and dev each carried a near-identical winston-backed Logger: the same
per-instance level field, the same `if (this.logLevel > LogLevel.X) return;`
guard in all five methods, and the same inverted winston levels map. Every
gating fix had to be made twice, and there was no process-wide way to pin the
dev package's level.
Move the winston construction and the gating into a single `WinstonLogger` in
core, parameterised by the format options that actually differ. `SimpleLogger`
becomes a preset over core's existing format chain (byte-identical rendering);
`AdkLogger` becomes an empty subclass, so no file under dev/src imports winston
any more.
A `WinstonLogger` now leaves its level unset until `setLogLevel` is called on
the instance, and resolves `this.logLevel ?? defaultLogLevel` at log time. The
exported `setLogLevel` writes that process-wide default, so one call reaches
loggers other packages constructed at import time, while an explicit per-instance
pin still wins.
Also fixes a latent bug the duplication had copied into both packages:
`log()` passed `level.toString()` ('0'..'3') to winston, which is not a key of
the levels map, so winston dropped the message and wrote "Unknown logger level"
to stderr. It now maps the enum to the winston level name.
AdkApiServer only pins its logger when the caller supplied a logLevel, so a
server logger that was never explicitly pinned follows the process default.
Adds core/test/utils/logger_gating_test.ts and dev/test/utils/logger_test.ts, each mocking only winston's createLogger so the real format chain is still exercised, plus two AdkApiServer constructor tests for the conditional level pin. Between them they pin the full 4x4 (threshold x level) gating matrix through both the named methods and log(), that log() emits at the winston level name rather than the numeric enum value, that the process-wide default reaches an instance constructed before setLogLevel was called, that an instance pin beats that default, and that SimpleLogger's rendered line is unchanged.
…stub cast
Review follow-ups, all reducing the change:
- Delete WINSTON_LEVEL_NAMES. It was a second hand-maintained copy of the
level table that already exists as the `levels` map passed to
winston.createLogger. LogLevel is a non-const numeric enum, so the reverse
mapping gives the same four names: LogLevel[level].toLowerCase().
- Collapse AdkLogger from an empty subclass to a re-export of WinstonLogger.
It added no method, field or override; the subclass only minted a second
constructor and prototype for the same behaviour. No call site subclasses it
or uses instanceof, so `export {WinstonLogger as AdkLogger}` is a drop-in.
- Delete the AdkLoggerOptions alias. Once AdkLogger stopped declaring its own
constructor the alias typed nothing: it has no reference in dev/src or
dev/test, and dev/src/index.ts never re-exported the module, so it was
unreachable from outside the package too.
- Type the server's agentLoader option as AgentLoaderLike, the two methods it
actually calls, instead of the concrete AgentLoader. The concrete class has
private fields, which is what forced every test double to launder itself
through `as unknown as`; the new constructor tests now need no cast.
SimpleLogger is deliberately kept. It is not behaviourless: the untouched
core/test/utils/logger_test.ts asserts the default logger's constructor name
is 'SimpleLogger', and inlining new WinstonLogger(DEFAULT_LOGGER_OPTIONS)
fails it with "expected 'WinstonLogger' to be 'SimpleLogger'".
This was referenced Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no existing issue.
Problem:
adk-jsships two near-identical winston-backed loggers —SimpleLoggerincore/src/utils/logger.tsandAdkLoggerindev/src/utils/logger.ts. Both carry the sameprivate logLevel: LogLevel = LogLevel.INFOfield, the same
if (this.logLevel > LogLevel.X) return;guard in each oflog/debug/info/warn/error, and the same inverted winstonlevelsmappaired with
level: 'error'. The only genuine difference is thatAdkLoggerbuilds its format chain from options while
SimpleLoggerhardcodes one.Two consequences:
living in both copies:
log()doesthis.logger.log(level.toString(), ...),but
LogLevelis a numeric enum, so it passes'0'..'3'— none of whichare keys of the winston levels map. Verified against the pinned
winston@3.19.0from this repo's lockfile: with the colorize format in thechain,
Logger.log(LogLevel.INFO, 'x')does not merely drop the message, itthrows
TypeError: colors[Colorizer.allColors[lookup]] is not a functionfrom
logform/colorize.js:73. (Before/after transcript below.)AdkLoggerinstances are per-object and are constructed at module load
(
dev/src/utils/agent_loader.ts:29) or per construction(
dev/src/server/adk_api_server.ts,dev/src/cli/cli.ts:200). A harnesscalling the exported
setLogLevel(LogLevel.ERROR)from@google/adkreachedonly core's
currentLogger; the dev-package loggers kept logging.Solution: extract one level-gated winston logger into
core, parameterisedonly by the format options that actually differ, and give it one process-wide
default level that both packages' loggers read at log time.
core/src/utils/logger.tsgainsWinstonLoggerOptionsandclass WinstonLogger implements Logger, which owns the winston construction(levels map,
level: 'error',Consoletransport) and the level gating(
isEnabled, one predicate used by all five methods).SimpleLoggerbecomes a preset:class SimpleLogger extends WinstonLoggerover a module-level
DEFAULT_LOGGER_OPTIONS. It stays module-private.WinstonLoggerleaveslogLevelunset untilsetLogLevel()is calledon that instance, and resolves
this.logLevel ?? defaultLogLevelon every logcall. Resolving at log time (rather than capturing in the constructor) is what
lets one
setLogLevel()reach a logger that was constructed at import time.setLogLevel()writes that module-leveldefaultLogLevelandstill forwards to the registry logger, so a caller-supplied custom
Loggerinstalled via
setLogger()keeps working unchanged.dev/src/utils/logger.tscollapses to a single re-export,export {WinstonLogger as AdkLogger} from '@google/adk';. The dev-packagename its three call sites already use is preserved without minting a second
constructor and prototype for identical behaviour; nothing subclasses it and
nothing uses
instanceof, so it is a drop-in. No file underdev/srcimportswinston any more (
grep -rn winston dev/srcreturns only a doc-commentreference).
log(), by deriving thewinston level name from the enum itself —
LogLevel[level].toLowerCase().LogLevelis a non-const numeric enum, so its reverse mapping yields exactlythe four keys of the
levelsmap the constructor already passes to winston;no second table has to be kept in agreement with it.
AdkApiServernow pins its logger only when the caller supplied alogLevel, so a server logger that was never explicitly pinned follows theprocess-wide default. The CLI is unaffected —
dev/src/cli/cli.tsalwayspasses an explicit
logLevel.Why this shape:
WinstonLoggerstays incore/src/utils/logger.tsratherthan moving to its own module — splitting it out creates an import cycle with
the
LogLevelenum that crashes at module init with a TDZReferenceError.WinstonLogger/WinstonLoggerOptionsare exported fromcore/src/common.tsbecause
devconsumes@google/adkas a package, so a non-exported symbol isunreachable from it.
ServerOptions.agentLoaderis typed asAgentLoaderLike—Pick<AgentLoader, 'listAgents' | 'getAgentFile'>, the only two methods the server ever calls onit — instead of the concrete class. The concrete
AgentLoaderhasprivatefields, which is what forces every test double in the package to launder
itself through
as unknown as; declaring the dependency structurally removesthat pressure at the root, and the new constructor tests need no cast.
Public API is additive only.
WinstonLoggerandWinstonLoggerOptionsareadded; nothing is removed or renamed.
dev/src/utils/logger.ts'sAdkLoggerOptionsalias is dropped: onceAdkLoggerstopped declaring its ownconstructor the alias typed nothing, it has zero references in
dev/srcordev/test, anddev/src/index.tsnever re-exported the module, so it wasunreachable from outside
@google/adk-devtoolsin the first place. WideningServerOptions.agentLoaderis not breaking for callers — a realAgentLoaderstill satisfies it.
Three intended behaviour changes:
setLogLevel()now also changes the level of built-in loggers in thedevpackage. Previously it touched only core's
currentLogger. This is the pointof the change.
AdkApiServerno longer forces its logger toINFOwhenoptions.logLevelis omitted. A caller that passes
options.loggerand omitslogLevelnowkeeps its own logger's level instead of having it silently overwritten.
Logger.log(level, ...)now emits at the right winston level instead ofthrowing / dropping the message.
No behaviour change to core's rendered output. Same format chain, same
order, same colorize/label/timestamp semantics — proved byte-for-byte below.
Deliberate scope limits (all called out in the task spec as non-goals, none
of them silent):
vitest.config.tsandtests/global_setup.tsare untouched;the now-unused
winstonentry indev/package.jsonis left in place ratherthan churning the lockfile; no
setDefaultLogLevelAPI is added (the existingsetLogLevelis the single source);LogLevel's numeric values are unchanged.Collision check (required before starting): scanned all 300 open PRs on the
fork. No PR implements this change. Three plausibly adjacent PRs were checked by
file list and none conflict in substance — #349 (
vitest.config.ts,tests/global_setup.ts— deliberately out of scope here), #365(
dev/src/utils/agent_loader.tsonly), and #193/#241 (adjacent export lines incore/src/common.ts). Branched frommain, not stacked.Disclosures:
any, no@ts-expect-error, noeslint-disable, no coverage pragma, and noas unknown as— the one cast anearlier revision of this branch had in the new server tests was removed by
typing the option as
AgentLoaderLikeinstead. The single remainingas unknown as AgentLoaderindev/test/server/adk_api_server_test.ts:238ispre-existing and untouched by this diff; it stays because its stub returns a
fake
AgentFile, andAgentFilehas private fields too, so removing it wouldmean growing a second structural type this change does not need.
SimpleLoggeris deliberately kept as a subclass. It looks behaviourless,but it is not: the untouched
core/test/utils/logger_test.ts:141asserts thedefault logger's
constructor.nameis'SimpleLogger'. Inliningnew WinstonLogger(DEFAULT_LOGGER_OPTIONS)at the two construction sites wastried and fails that pre-existing test with
expected 'WinstonLogger' to be 'SimpleLogger'. The class name is observablepublic behaviour here, so the subclass earns its keep;
AdkLogger, whichnothing asserts on, was collapsed to a re-export.
WinstonLogger.error()is unreachable today, becauseLogLevel.ERRORis the maximum level, soisEnabled(ERROR)is always true.It is carried over verbatim from both original implementations; removing it
would make
error()the one method that reads as ungated. It is the onlyuncovered branch in the new code (see coverage below).
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
New:
core/test/utils/logger_gating_test.ts(42 tests) anddev/test/utils/logger_test.ts(19 tests). Each mocks only winston'screateLogger, spreading the real namespace sowinston.formatandwinston.transportsstay real — the format chain is asserted by running thecaptured config's
format.transform(...), not by stubbing it. A new top-leveldescribe('AdkApiServer log level')is appended todev/test/server/adk_api_server_test.ts; it is a sibling of the existingdescribe('AdkWebServer')so it does not inherit that block's server-startingbeforeEach. No existing test was edited, weakened, skipped or deleted; thepre-existing
core/test/utils/logger_test.tsstill passes untouched.Between them the new tests pin: the full 4x4 (threshold x level) gating matrix
through the named methods and through
log(), in both packages; thatlog()emits at the winston level name rather than the numeric enum value; the INFO
default for an unpinned instance; that the process-wide default reaches an
instance constructed before
setLogLevel()was called; that an instance pinbeats the default; message joining; the inverted
levelsmap andlevel: 'error';label / uppercased level / timestamp /
printFormatwiring, thetimestamp: falsecase and the defaultprintFormat; and thatSimpleLogger'srendered line is unchanged.
Also green on the wider slice that touches the logger API, and on the whole
devpackage:The single
unit:devfailure iscli_create_test.ts > should handle Vertex AI selection with gcloud defaults. It is pre-existing and unrelated — verifiedby checking out the branch base (
b390217e) and running that file alone, whereit fails identically.
Static checks:
npm run lint,npm run format:checkandnpm run docs:checkall exit 0.
npx tsc --noEmitproduces the identical per-file error set beforeand after this change (the repo's
ts:checkhas a large pre-existing backlog incore/test); zero errors incore/srcordev/src.Coverage. Measured over the two changed modules with the suites above:
dev/src/utils/logger.tsis at 100%. Incore/src/utils/logger.tsthe threeuncovered spans are: 153-154, the provably-unreachable
error()guard describedabove; and 230-231 / 242-243, the pre-existing
logger.warn/logger.errorfacade forwarders, which this change does not touch. Every new line other than
the unreachable guard is exercised.
Proof that each test can fail. Eleven mutations were applied one at a time to
the source and the targeted suites re-run; every one was killed. Reverted after
each. (Re-run in full after the review revisions, against the revised code.)
this.logLevel ?? defaultLogLevel->this.logLevel ?? LogLevel.INFOexpected [ { level: 'info', …(1) }, …(1) ] to deeply equal [ { level: 'error', …(1) } ]in both packages' "process-wide default" testsprivate logLevel?: LogLevel = defaultLogLevel)this.logger.log(level.toString(), ...)expected [ { level: '1', message: 'named' } ] to deeply equal [ { level: 'info', message: 'named' } ]isEnabled<=-><expected [] to deeply equal [ { level: 'debug', …(1) } ]this.logger.setLogLevel(options.logLevel ?? LogLevel.INFO)inAdkApiServerexpected [ 1 ] to deeply equal []DEFAULT_LOGGER_OPTIONS.label'ADK'->'ADK2'expected '…INFO…: [ADK2] 2026…' to match /^\S*INFO\S*: \[ADK\] \S+ hello world$/expected '…info…' to contain 'INFO'timestamp()regardless of the optionexpected 'timestamp=2026-…Z' to be 'timestamp=undefined'printFormatfallbackexpected 'undefined' to be 'plain'level: 'error'->level: 'debug'in the winston configexpected 'debug' to be 'error'Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
All of the following were run locally on the pushed commit, against the built
packages (
npm run build), with no mocks — real winston writing to realstdout in a real Node process.
1. Install the built packages into a scratch project.
2. Run a script that exercises the feature end to end — construct an
AdkLoggerbefore touching the level, calllog()with the numeric enum,then
setLogLevel(LogLevel.ERROR)from@google/adk, then start a realAdkApiServerand issue a realfetchto/list-apps:Results, run against the branch base (
b390217e) and against this branch, withthe packages reinstalled from each build:
3. Rendered-output parity for core's logger. In one process, log the same
message through the reconstructed pre-change
SimpleLoggerchain and throughthe new
getLogger(), capture both stdout writes and normalise the timestamp:4. The dev server's log format is visually unchanged.
and with
--verbosethe same request still renders identically (the CLI passesan explicit
logLevel, so the conditional pin takes effect). Notedev/srccontains no
logger.debugcall sites at all, so--verboseemits no extra DEBUGlines from the dev server itself either before or after this change.
Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.