2026-05-10 13:12:44 +08:00

257 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GMP-Agent 统一日志收集模块
* logger.js · GH-GMP-004 · 录册A02
*
* 功能:
* - 每个GMP模块独立日志文件install/runtime/error
* - 结构化JSON日志时间戳+级别+模块+消息+元数据)
* - 按大小自动轮转默认10MB
* - 日志查询API按模块/级别/时间范围过滤)
* - 统一存储到 /guanghu/repo/gmp-agent/logs/
*
* 环境要求Node.js 20+ · 纯标准库 · 无第三方依赖
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
// ─── 配置 ───────────────────────────────────────────────
const DEFAULT_CONFIG = {
logDir: process.env.GMP_LOG_DIR || path.join(__dirname, '..', 'logs'),
maxFileSize: parseInt(process.env.GMP_LOG_MAX_SIZE) || 10 * 1024 * 1024, // 10MB
maxRotatedFiles: parseInt(process.env.GMP_LOG_MAX_FILES) || 5,
levels: ['debug', 'info', 'warn', 'error', 'fatal'],
minLevel: process.env.GMP_LOG_LEVEL || 'info',
enableConsole: process.env.GMP_LOG_CONSOLE !== 'false',
};
// ─── 日志级别权重 ──────────────────────────────────────────
const LEVEL_WEIGHT = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 };
// ─── Logger 类 ──────────────────────────────────────────
class GmpLogger extends EventEmitter {
/**
* @param {string} moduleName - GMP模块名称如 'webhook', 'installer'
* @param {object} [config] - 覆盖默认配置
*/
constructor(moduleName, config = {}) {
super();
this.moduleName = moduleName;
this.config = { ...DEFAULT_CONFIG, ...config };
this.minLevelWeight = LEVEL_WEIGHT[this.config.minLevel] ?? 1;
// 确保日志目录存在
this._ensureDir(this.config.logDir);
this._ensureDir(path.join(this.config.logDir, moduleName));
}
// ─── 公共API ────────────────────────────────────────────
debug(message, meta = {}) { return this._log('debug', message, meta); }
info(message, meta = {}) { return this._log('info', message, meta); }
warn(message, meta = {}) { return this._log('warn', message, meta); }
error(message, meta = {}) { return this._log('error', message, meta); }
fatal(message, meta = {}) { return this._log('fatal', message, meta); }
/**
* 记录安装日志(专用通道)
*/
install(message, meta = {}) {
return this._log('info', message, { ...meta, channel: 'install' });
}
/**
* 查询日志
* @param {object} filter - { level?, since?, until?, limit?, channel? }
* @returns {Array<object>} 匹配的日志条目
*/
query(filter = {}) {
const { level, since, until, limit = 100, channel } = filter;
const logDir = path.join(this.config.logDir, this.moduleName);
const entries = [];
// 读取所有日志文件
let files;
try {
files = fs.readdirSync(logDir).filter(f => f.endsWith('.jsonl')).sort();
} catch {
return entries;
}
for (const file of files) {
const content = fs.readFileSync(path.join(logDir, file), 'utf-8');
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
// 按级别过滤
if (level && entry.level !== level) continue;
// 按通道过滤
if (channel && entry.channel !== channel) continue;
// 按时间范围过滤
if (since && entry.timestamp < since) continue;
if (until && entry.timestamp > until) continue;
entries.push(entry);
if (entries.length >= limit) return entries;
} catch {
// 跳过损坏的行
}
}
}
return entries;
}
/**
* 获取日志统计
* @returns {object} { total, byLevel, byChannel }
*/
stats() {
const all = this.query({ limit: Infinity });
const byLevel = {};
const byChannel = {};
for (const entry of all) {
byLevel[entry.level] = (byLevel[entry.level] || 0) + 1;
if (entry.channel) {
byChannel[entry.channel] = (byChannel[entry.channel] || 0) + 1;
}
}
return { total: all.length, byLevel, byChannel };
}
// ─── 内部方法 ────────────────────────────────────────────
_log(level, message, meta) {
if (LEVEL_WEIGHT[level] < this.minLevelWeight) return null;
const entry = {
timestamp: new Date().toISOString(),
level,
module: this.moduleName,
message,
...meta,
};
// 写入文件
const channel = meta.channel || 'runtime';
const logFile = path.join(
this.config.logDir,
this.moduleName,
`${channel}.jsonl`
);
const line = JSON.stringify(entry) + '\n';
try {
fs.appendFileSync(logFile, line, 'utf-8');
this._checkRotation(logFile);
} catch (err) {
// 如果写入失败,输出到控制台
console.error(`[GmpLogger] Failed to write log: ${err.message}`);
}
// 控制台输出
if (this.config.enableConsole) {
const prefix = `[${entry.timestamp}] [${level.toUpperCase()}] [${this.moduleName}]`;
if (level === 'error' || level === 'fatal') {
console.error(`${prefix} ${message}`);
} else {
console.log(`${prefix} ${message}`);
}
}
// 触发事件(供外部监听)
this.emit('log', entry);
return entry;
}
_checkRotation(logFile) {
try {
const stat = fs.statSync(logFile);
if (stat.size >= this.config.maxFileSize) {
this._rotate(logFile);
}
} catch {
// 文件不存在或无法读取,跳过
}
}
_rotate(logFile) {
const max = this.config.maxRotatedFiles;
// 删除最老的轮转文件
const oldest = `${logFile}.${max}`;
if (fs.existsSync(oldest)) {
fs.unlinkSync(oldest);
}
// 依次重命名
for (let i = max - 1; i >= 1; i--) {
const from = `${logFile}.${i}`;
const to = `${logFile}.${i + 1}`;
if (fs.existsSync(from)) {
fs.renameSync(from, to);
}
}
// 当前文件变为 .1
fs.renameSync(logFile, `${logFile}.1`);
}
_ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
// ─── 工厂函数 ──────────────────────────────────────────
/** 已创建的logger缓存 */
const loggers = new Map();
/**
* 获取或创建模块日志实例
* @param {string} moduleName
* @param {object} [config]
* @returns {GmpLogger}
*/
function getLogger(moduleName, config) {
if (!loggers.has(moduleName)) {
loggers.set(moduleName, new GmpLogger(moduleName, config));
}
return loggers.get(moduleName);
}
/**
* 查询所有模块日志
* @param {object} filter - { module?, level?, since?, until?, limit? }
* @returns {Array<object>}
*/
function queryAll(filter = {}) {
const { module: mod, ...rest } = filter;
if (mod) {
const logger = getLogger(mod);
return logger.query(rest);
}
// 查询所有已注册模块
const results = [];
for (const [, logger] of loggers) {
results.push(...logger.query(rest));
}
// 按时间排序
results.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
if (filter.limit) {
return results.slice(0, filter.limit);
}
return results;
}
// ─── 导出 ──────────────────────────────────────────────
module.exports = {
GmpLogger,
getLogger,
queryAll,
DEFAULT_CONFIG,
LEVEL_WEIGHT,
};