perfect-logger
Version:
A zero-dependency, isomorphic logger for Node.js and Browsers with plugin support.
204 lines (203 loc) • 8.54 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileAppender = void 0;
const BaseAppender_1 = require("./BaseAppender");
const environment_1 = require("../utils/environment");
const constants_1 = require("../constants");
const safeStringify_1 = require("../utils/safeStringify");
// Node.js modules are conditionally required
let fsPromises = null;
let fsModule = null;
let pathModule = null;
if ((0, environment_1.isNode)()) {
try {
fsModule = require('fs');
fsPromises = require('fs').promises;
pathModule = require('path');
}
catch (e) {
console.error('FileAppender is only available in Node.js environments.');
}
}
const DEFAULT_FORMAT = '{date} | {time} | {level} | {namespace} | {message}';
const DEFAULT_FILENAME = 'app.log';
class FileAppender extends BaseAppender_1.BaseAppender {
constructor(config = {}) {
super('FileAppender', config, { minLevel: constants_1.LogLevel.INFO });
this.currentFileSize = 0;
this.currentFileDateMarker = null;
if (!fsPromises || !fsModule || !pathModule || !process) {
throw new Error('FileAppender cannot be used in this environment.');
}
this.logDirectory = config.logDirectory || pathModule.join(process.cwd(), 'logs');
this.fileName = config.fileName || DEFAULT_FILENAME;
this.formatTemplate = config.format || DEFAULT_FORMAT;
this.rotation = config.rotation;
this.maxSize = config.maxSize || null;
this.maxFiles = config.maxFiles || null;
this.dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric', month: '2-digit', day: '2-digit', timeZone: this.timezone,
});
this.timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, timeZone: this.timezone,
});
this.currentFilePath = this.getCurrentFilename();
this.initializeState();
}
async initializeState() {
if (!fsModule.existsSync(this.logDirectory)) {
fsModule.mkdirSync(this.logDirectory, { recursive: true });
}
this.currentFilePath = this.getCurrentFilename();
if (this.rotation) {
this.currentFileDateMarker = this.getDateMarker(new Date());
}
try {
const stats = await fsPromises.stat(this.currentFilePath);
this.currentFileSize = stats.size;
}
catch (e) {
this.currentFileSize = 0;
}
}
handle(entry) {
this.handleBatch([entry]);
}
async handleBatch(entries) {
if (!fsPromises)
return;
const logLines = entries
.filter(entry => entry.level >= this.minLevel)
.map(entry => this.formatLog(entry))
.join('\n');
if (!logLines) {
return;
}
const logBuffer = Buffer.from(logLines + '\n', 'utf-8');
try {
await this.checkForRotation(logBuffer.length);
await fsPromises.appendFile(this.currentFilePath, logBuffer);
this.currentFileSize += logBuffer.length;
}
catch (e) {
console.error('Error writing to log file:', e);
}
}
async checkForRotation(bytesToAdd) {
const timeBoundaryReached = this.rotation && this.getDateMarker(new Date()) !== this.currentFileDateMarker;
const sizeBoundaryReached = this.maxSize !== null && (this.currentFileSize + bytesToAdd > this.maxSize);
if (timeBoundaryReached || sizeBoundaryReached) {
await this.rotate(timeBoundaryReached);
}
}
async rotate(timeBased) {
if (!pathModule)
return;
const oldPath = this.currentFilePath;
// Determine the new path for the current log file
if (timeBased) {
this.currentFileDateMarker = this.getDateMarker(new Date());
}
this.currentFilePath = this.getCurrentFilename();
this.currentFileSize = 0;
// Archive the old file
try {
// If file doesn't exist, no need to rotate it.
await fsPromises.access(oldPath);
}
catch {
return;
}
const ext = pathModule.extname(this.fileName);
const baseName = pathModule.basename(this.fileName, ext);
const archives = await this.getArchives(baseName);
const archiveName = timeBased
? `${baseName}-${this.getDateMarker(new Date(Date.now() - 1))}${ext}` // Use previous date marker
: `${baseName}.${archives.length + 1}${ext}`;
const archivePath = pathModule.join(this.logDirectory, archiveName);
try {
await fsPromises.rename(oldPath, archivePath);
}
catch (e) {
console.error(`Failed to rotate log file from ${oldPath} to ${archivePath}`, e);
return;
}
// Prune
await this.prune([archivePath, ...archives]);
}
async prune(archives) {
if (!this.maxFiles || !pathModule)
return;
const filesToProcess = archives.sort().reverse(); // Newest first
// Delete oldest files
if (filesToProcess.length > this.maxFiles) {
const filesToDelete = filesToProcess.slice(this.maxFiles);
for (const file of filesToDelete) {
try {
await fsPromises.unlink(file);
}
catch (e) {
console.error(`Failed to delete old log file: ${file}`, e);
}
}
}
}
async getArchives(baseName) {
if (!pathModule)
return [];
const files = await fsPromises.readdir(this.logDirectory);
const ext = pathModule.extname(this.fileName);
const regex = new RegExp(`^${baseName}[-.]`);
return files
.filter(f => f.startsWith(baseName) && f !== this.fileName && regex.test(f))
.map(f => pathModule.join(this.logDirectory, f));
}
getCurrentFilename() {
if (!pathModule)
return '';
if (this.rotation && this.maxSize === null) { // Purely time-based
return pathModule.join(this.logDirectory, `${pathModule.basename(this.fileName, pathModule.extname(this.fileName))}-${this.getDateMarker(new Date())}${pathModule.extname(this.fileName)}`);
}
return pathModule.join(this.logDirectory, this.fileName);
}
getDateMarker(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
if (this.rotation === 'hourly') {
const hour = date.getHours().toString().padStart(2, '0');
return `${year}-${month}-${day}T${hour}`;
}
return `${year}-${month}-${day}`;
}
formatLog(entry) {
var _a, _b, _c;
const parts = this.dateFormatter.formatToParts(entry.timestamp);
const year = (_a = parts.find(p => p.type === 'year')) === null || _a === void 0 ? void 0 : _a.value;
const month = (_b = parts.find(p => p.type === 'month')) === null || _b === void 0 ? void 0 : _b.value;
const day = (_c = parts.find(p => p.type === 'day')) === null || _c === void 0 ? void 0 : _c.value;
const date = `${year}/${month}/${day}`;
const baseTime = this.timeFormatter.format(entry.timestamp);
const milliseconds = entry.timestamp.getMilliseconds().toString().padStart(3, '0');
const time = `${baseTime}.${milliseconds}`;
const level = (constants_1.LogLevel[entry.level] || 'UNKNOWN');
let logLine = this.formatTemplate
.replace('{date}', date)
.replace('{time}', time)
.replace('{level}', level)
.replace('{namespace}', entry.namespace)
.replace('{message}', entry.message)
.replace('{context}', '')
.replace('{error}', '');
if (entry.context) {
const prettyContext = (0, safeStringify_1.safeStringify)(entry.context, null, 4);
const indentedContext = prettyContext.split('\n').map(line => `~ ${line}`).join('\n');
logLine += `\n${indentedContext}`;
}
if (entry.error) {
logLine += `\n${entry.error.stack || entry.error.message}`;
}
return logLine;
}
}
exports.FileAppender = FileAppender;