11// src/core/logger.ts
2- // Simple structured logging framework
2+ // Structured logging framework with automatic rotation
33
4- import { writeFileSync , appendFileSync , existsSync , mkdirSync } from 'fs' ;
4+ import { writeFileSync , appendFileSync , existsSync , mkdirSync , readdirSync , statSync , unlinkSync , readFileSync , createReadStream , createWriteStream } from 'fs' ;
55import { join } from 'path' ;
66import { homedir } from 'os' ;
7+ import { createGzip } from 'zlib' ;
8+ import { pipeline } from 'stream' ;
9+ import { promisify } from 'util' ;
10+
11+ const pipelineAsync = promisify ( pipeline ) ;
712
813export type LogLevel = 'debug' | 'info' | 'warn' | 'error' ;
914
15+ export interface LoggerConfig {
16+ maxFileSize : number ; // bytes before rotation (default: 10MB)
17+ maxFiles : number ; // max log files to keep (default: 5)
18+ compress : boolean ; // compress rotated logs with gzip
19+ }
20+
1021const LOG_DIR = join ( homedir ( ) , '.localcode' , 'logs' ) ;
1122const LOG_FILE = join ( LOG_DIR , `localcode-${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .log` ) ;
1223
24+ const DEFAULT_CONFIG : LoggerConfig = {
25+ maxFileSize : 10 * 1024 * 1024 , // 10MB
26+ maxFiles : 5 ,
27+ compress : true ,
28+ } ;
29+
1330let currentLevel : LogLevel = 'info' ;
31+ let config : LoggerConfig = { ...DEFAULT_CONFIG } ;
32+ let rotationInProgress = false ;
1433
1534export function setLogLevel ( level : LogLevel ) {
1635 currentLevel = level ;
1736}
1837
38+ export function setLoggerConfig ( cfg : Partial < LoggerConfig > ) {
39+ config = { ...config , ...cfg } ;
40+ }
41+
1942const LEVEL_PRIORITY : Record < LogLevel , number > = { debug : 0 , info : 1 , warn : 2 , error : 3 } ;
2043
2144function shouldLog ( level : LogLevel ) : boolean {
@@ -32,11 +55,99 @@ function formatLog(level: LogLevel, message: string, context?: Record<string, un
3255 return JSON . stringify ( entry ) ;
3356}
3457
58+ /**
59+ * Rotate log files when size limit is reached.
60+ * Oldest files are deleted or compressed based on config.
61+ */
62+ async function rotateLogs ( ) : Promise < void > {
63+ if ( rotationInProgress ) return ;
64+ rotationInProgress = true ;
65+
66+ try {
67+ if ( ! existsSync ( LOG_DIR ) ) return ;
68+
69+ // Get current file size
70+ if ( ! existsSync ( LOG_FILE ) ) {
71+ rotationInProgress = false ;
72+ return ;
73+ }
74+
75+ const stats = statSync ( LOG_FILE ) ;
76+ if ( stats . size < config . maxFileSize ) {
77+ rotationInProgress = false ;
78+ return ;
79+ }
80+
81+ // Rotate: rename current file with timestamp suffix
82+ const timestamp = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, '-' ) ;
83+ const rotatedFile = LOG_FILE . replace ( '.log' , `-${ timestamp } .log` ) ;
84+ writeFileSync ( rotatedFile , '' , 'utf8' ) ; // Create empty file
85+ const content = readFileSync ( LOG_FILE , 'utf8' ) ;
86+ appendFileSync ( rotatedFile , content , 'utf8' ) ;
87+ writeFileSync ( LOG_FILE , '' , 'utf8' ) ; // Truncate current file
88+
89+ // Compress if enabled
90+ if ( config . compress ) {
91+ try {
92+ const gzFile = rotatedFile + '.gz' ;
93+ await pipelineAsync (
94+ createReadStream ( rotatedFile ) ,
95+ createGzip ( ) ,
96+ createWriteStream ( gzFile ) ,
97+ ) ;
98+ unlinkSync ( rotatedFile ) ; // Remove uncompressed version
99+ } catch {
100+ // If compression fails, keep uncompressed file
101+ }
102+ }
103+
104+ // Clean up old files
105+ cleanupOldLogs ( ) ;
106+
107+ } catch ( err ) {
108+ // If rotation fails, continue logging to current file
109+ // Don't crash the app over log rotation
110+ } finally {
111+ rotationInProgress = false ;
112+ }
113+ }
114+
115+ function cleanupOldLogs ( ) : void {
116+ try {
117+ if ( ! existsSync ( LOG_DIR ) ) return ;
118+
119+ const files = readdirSync ( LOG_DIR )
120+ . filter ( f => f . startsWith ( 'localcode-' ) && ( f . endsWith ( '.log' ) || f . endsWith ( '.log.gz' ) ) )
121+ . map ( f => ( {
122+ name : f ,
123+ path : join ( LOG_DIR , f ) ,
124+ mtime : statSync ( join ( LOG_DIR , f ) ) . mtime . getTime ( ) ,
125+ } ) )
126+ . sort ( ( a , b ) => b . mtime - a . mtime ) ; // Newest first
127+
128+ // Delete oldest files beyond maxFiles limit
129+ for ( let i = config . maxFiles ; i < files . length ; i ++ ) {
130+ try {
131+ unlinkSync ( files [ i ] . path ) ;
132+ } catch {
133+ // Skip files that can't be deleted
134+ }
135+ }
136+ } catch {
137+ // If cleanup fails, continue without cleaning
138+ }
139+ }
140+
35141function writeLog ( level : LogLevel , message : string , context ?: Record < string , unknown > ) {
36142 if ( ! shouldLog ( level ) ) return ;
37143 try {
38144 if ( ! existsSync ( LOG_DIR ) ) mkdirSync ( LOG_DIR , { recursive : true } ) ;
39145 appendFileSync ( LOG_FILE , formatLog ( level , message , context ) + '\n' ) ;
146+
147+ // Check if rotation needed (non-blocking)
148+ rotateLogs ( ) . catch ( ( ) => {
149+ // Ignore rotation errors
150+ } ) ;
40151 } catch {
41152 // If logging fails, silently skip — don't crash the app
42153 }
0 commit comments