-
Notifications
You must be signed in to change notification settings - Fork 0
feat(desktop-main): rotate Winston logs in userData with daily rotation #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
CoderCoco
merged 19 commits into
main
from
claude/issue-149-winston-logger-userdata-logs
May 24, 2026
Merged
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
7fc5abf
chore(desktop-main): T1 - add winston-daily-rotate-file ^5.0.0 depend…
CoderCoco a1090c3
feat(desktop-main): T2 - rewrite logger.ts to createLogger factory wi…
CoderCoco cdccf99
feat(desktop-main): T3 - call createLogger with userData/logs path be…
CoderCoco 7180700
feat(desktop-main): T4 - add DiagnosticsService with log tail and pat…
CoderCoco e35baa5
feat(desktop-main): T5 - add DiagnosticsController with GET tail and …
CoderCoco f2a6856
feat(desktop-main): T6 - register DiagnosticsService and controller i…
CoderCoco dec71d5
feat(web): T7 - add diagnosticsTail and diagnosticsLogPath to api ser…
CoderCoco 8538c99
feat(web): T8 - add DiagnosticsPanel component with 5s polling and au…
CoderCoco a52f23e
feat(web): T9 - add Diagnostics section to SettingsPage with Diagnost…
CoderCoco 51c117f
perf(desktop-main): tail log from end-of-file to avoid full read on e…
CoderCoco 3f635f2
fix(desktop-main): guard Electron API usage, fix bytesRead, lazy elec…
CoderCoco f8d64a0
fix(desktop-main): per-transport formats and cancel-safe fetchData
CoderCoco f36452b
fix(desktop-main): lazy-load electron via dynamic import after guard
CoderCoco b6a23b4
test(web): remove clearAllTimers from global afterEach in Diagnostics…
CoderCoco 3e82e4e
fix(web): retry logPath fetch until successful in DiagnosticsPanel
CoderCoco 8c6dd60
fix(desktop-main): default DIAGNOSTICS_LOG_DIR to os.tmpdir() in non-…
CoderCoco f4de79c
fix(desktop-main): peek one byte before window to avoid dropping comp…
CoderCoco a1133ec
test(desktop-main): use path.join/dirname in getTodayLogPath assertions
CoderCoco a5c8211
test(desktop-main): remove empty beforeEach from logger singleton suite
CoderCoco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
46 changes: 46 additions & 0 deletions
46
app/packages/desktop-main/src/controllers/diagnostics.controller.test.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import 'reflect-metadata'; | ||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { DiagnosticsController } from './diagnostics.controller.js'; | ||
| import type { DiagnosticsService } from '../services/DiagnosticsService.js'; | ||
|
|
||
| vi.mock('../logger.js', () => ({ | ||
| logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | ||
| })); | ||
|
|
||
| /** Build a DiagnosticsService stub. */ | ||
| function makeDiagnostics(): DiagnosticsService { | ||
| return { | ||
| readTail: vi.fn().mockResolvedValue(['line1', 'line2', 'line3']), | ||
| getTodayLogPath: vi.fn().mockReturnValue('/var/log/app/main-2026-05-23.log'), | ||
| } as unknown as DiagnosticsService; | ||
| } | ||
|
|
||
| describe('DiagnosticsController', () => { | ||
| describe('getTail', () => { | ||
| it('should return lines from DiagnosticsService', async () => { | ||
| const svc = makeDiagnostics(); | ||
| const result = await new DiagnosticsController(svc).getTail(); | ||
| expect(result).toEqual({ lines: ['line1', 'line2', 'line3'] }); | ||
| }); | ||
|
|
||
| it('should call DiagnosticsService.readTail with 500 lines', async () => { | ||
| const svc = makeDiagnostics(); | ||
| await new DiagnosticsController(svc).getTail(); | ||
| expect(svc.readTail).toHaveBeenCalledWith(500); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getPath', () => { | ||
| it('should return the current log path from DiagnosticsService', () => { | ||
| const svc = makeDiagnostics(); | ||
| const result = new DiagnosticsController(svc).getPath(); | ||
| expect(result).toEqual({ path: '/var/log/app/main-2026-05-23.log' }); | ||
| }); | ||
|
|
||
| it('should delegate to DiagnosticsService.getTodayLogPath', () => { | ||
| const svc = makeDiagnostics(); | ||
| new DiagnosticsController(svc).getPath(); | ||
| expect(svc.getTodayLogPath).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); |
21 changes: 21 additions & 0 deletions
21
app/packages/desktop-main/src/controllers/diagnostics.controller.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { Controller, Get } from '@nestjs/common'; | ||
| import { DiagnosticsService } from '../services/DiagnosticsService.js'; | ||
|
|
||
| /** Exposes local application log data for operator diagnostics. */ | ||
| @Controller('diagnostics') | ||
| export class DiagnosticsController { | ||
| constructor(private readonly diagnostics: DiagnosticsService) {} | ||
|
|
||
| /** Returns the last 500 lines from today's local log file. */ | ||
| @Get('tail') | ||
| async getTail(): Promise<{ lines: string[] }> { | ||
| const lines = await this.diagnostics.readTail(500); | ||
| return { lines }; | ||
| } | ||
|
|
||
| /** Returns the absolute path of today's local log file. */ | ||
| @Get('path') | ||
| getPath(): { path: string } { | ||
| return { path: this.diagnostics.getTodayLogPath() }; | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import winston from 'winston'; | ||
| import DailyRotateFile from 'winston-daily-rotate-file'; | ||
|
|
||
| /** | ||
| * Re-import logger module fresh for each test that cares about the singleton | ||
| * state. We use a plain import at the top for the factory tests; the | ||
| * singleton re-assignment tests use the same module reference. | ||
| */ | ||
| import { createLogger, logger as initialLogger } from './logger.js'; | ||
|
|
||
| describe('createLogger', () => { | ||
| it('should return a winston.Logger instance', () => { | ||
| const result = createLogger('/tmp/test-logs'); | ||
| // Winston's createLogger returns a DerivedLogger that extends EventEmitter. | ||
| // Check the duck-typed API surface rather than constructor identity, which | ||
| // can differ when the same package is loaded from two module cache entries. | ||
| expect(typeof result.info).toBe('function'); | ||
| expect(typeof result.error).toBe('function'); | ||
| expect(typeof result.debug).toBe('function'); | ||
| expect(Array.isArray(result.transports)).toBe(true); | ||
| }); | ||
|
|
||
| it('should include a DailyRotateFile transport in the transports array', () => { | ||
| const result = createLogger('/tmp/test-logs'); | ||
| const hasRotate = result.transports.some((t) => t instanceof DailyRotateFile); | ||
| expect(hasRotate).toBe(true); | ||
| }); | ||
|
|
||
| it('should include a Console transport in the transports array', () => { | ||
| const result = createLogger('/tmp/test-logs'); | ||
| const hasConsole = result.transports.some( | ||
| (t) => t instanceof winston.transports.Console, | ||
| ); | ||
| expect(hasConsole).toBe(true); | ||
| }); | ||
|
|
||
| it('should configure the DailyRotateFile transport with the provided logDir', () => { | ||
| const logDir = '/tmp/my-custom-log-dir'; | ||
| const result = createLogger(logDir); | ||
| const rotateTransport = result.transports.find( | ||
| (t) => t instanceof DailyRotateFile, | ||
| ) as InstanceType<typeof DailyRotateFile> | undefined; | ||
|
|
||
| expect(rotateTransport).toBeDefined(); | ||
| // The `dirname` option is stored as `options.dirname` on the transport. | ||
| const opts = (rotateTransport as unknown as { options: { dirname: string } }) | ||
| .options; | ||
| expect(opts.dirname).toBe(logDir); | ||
| }); | ||
| }); | ||
|
|
||
| describe('logger singleton', () => { | ||
| beforeEach(() => { | ||
| // Reset to console-only by re-creating; each test starts fresh. | ||
| }); | ||
|
|
||
| it('should reassign the exported logger binding when createLogger is called', async () => { | ||
| // Capture the initial (console-only) reference. | ||
| const beforeCall = initialLogger; | ||
|
CoderCoco marked this conversation as resolved.
|
||
|
|
||
| // createLogger mutates the exported binding. | ||
| const returned = createLogger('/tmp/test-logs-singleton'); | ||
|
|
||
| // The returned value and the freshly-imported binding must be the same object. | ||
| // We re-import via the same module to read the live binding. | ||
| const { logger: afterCall } = await import('./logger.js'); | ||
| expect(afterCall).toBe(returned); | ||
| // And it must differ from the pre-call fallback logger. | ||
| expect(afterCall).not.toBe(beforeCall); | ||
| }); | ||
| }); | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,72 @@ | ||
| import winston from 'winston'; | ||
| import DailyRotateFile from 'winston-daily-rotate-file'; | ||
|
|
||
| const isDev = process.env['NODE_ENV'] !== 'production'; | ||
|
|
||
| export const logger = winston.createLogger({ | ||
| level: isDev ? 'debug' : 'info', | ||
| format: isDev | ||
| ? winston.format.combine( | ||
| winston.format.colorize(), | ||
| winston.format.timestamp({ format: 'HH:mm:ss' }), | ||
| winston.format.printf((info) => { | ||
| const { timestamp, level, message, ...meta } = info as Record<string, unknown>; | ||
| const metaStr = Object.keys(meta).length | ||
| ? '\n' + JSON.stringify(meta, null, 2) | ||
| : ''; | ||
| return `${timestamp} [${level}] ${message}${metaStr}`; | ||
| }), | ||
| ) | ||
| : winston.format.combine( | ||
| winston.format.timestamp(), | ||
| winston.format.json(), | ||
| ), | ||
| transports: [new winston.transports.Console()], | ||
| const devPrintf = winston.format.printf((info) => { | ||
| const { timestamp, level, message, ...meta } = info as Record<string, unknown>; | ||
| const metaStr = Object.keys(meta).length ? '\n' + JSON.stringify(meta, null, 2) : ''; | ||
| return `${timestamp} [${level}] ${message}${metaStr}`; | ||
| }); | ||
|
|
||
| /** Format for the Console transport — colorized in dev for readability. */ | ||
| const consoleFormat = isDev | ||
| ? winston.format.combine( | ||
| winston.format.colorize(), | ||
| winston.format.timestamp({ format: 'HH:mm:ss' }), | ||
| devPrintf, | ||
| ) | ||
| : winston.format.combine(winston.format.timestamp(), winston.format.json()); | ||
|
|
||
| /** Format for file transports — no ANSI escape codes, safe for log parsers. */ | ||
| const fileFormat = isDev | ||
| ? winston.format.combine(winston.format.timestamp({ format: 'HH:mm:ss' }), devPrintf) | ||
| : winston.format.combine(winston.format.timestamp(), winston.format.json()); | ||
|
|
||
| /** | ||
| * Creates a console-only fallback logger used as the initial singleton value | ||
| * before {@link createLogger} is called by main.ts. | ||
| */ | ||
| function createConsoleOnlyLogger(): winston.Logger { | ||
| return winston.createLogger({ | ||
| level: isDev ? 'debug' : 'info', | ||
| format: consoleFormat, | ||
| transports: [new winston.transports.Console()], | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Module-level logger singleton. Initially console-only; reassigned (as a | ||
| * live ESM binding) when {@link createLogger} is called from main.ts so that | ||
| * all existing importers automatically see the upgraded instance. | ||
| */ | ||
| export let logger: winston.Logger = createConsoleOnlyLogger(); | ||
|
|
||
| /** | ||
| * Creates a full winston logger with both a Console transport and a | ||
| * DailyRotateFile transport that writes to `logDir`. Also reassigns the | ||
| * exported {@link logger} singleton so that modules that imported it before | ||
| * `main.ts` started still get the upgraded instance. | ||
| * | ||
| * @param logDir - Directory in which daily log files will be written. | ||
| * @returns The newly created {@link winston.Logger} instance. | ||
| */ | ||
| export function createLogger(logDir: string): winston.Logger { | ||
| const newLogger = winston.createLogger({ | ||
| level: isDev ? 'debug' : 'info', | ||
| transports: [ | ||
| new winston.transports.Console({ format: consoleFormat }), | ||
| new DailyRotateFile({ | ||
| format: fileFormat, | ||
| dirname: logDir, | ||
| filename: 'main-%DATE%.log', | ||
| datePattern: 'YYYY-MM-DD', | ||
| maxFiles: '14d', | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| // Reassign the live ESM binding so importers see the upgraded instance. | ||
| logger = newLogger; | ||
| return newLogger; | ||
| } |
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.