136 lines
3.6 KiB
JavaScript
136 lines
3.6 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs-extra');
|
|
const path = require('path');
|
|
const debug = require('debug')('agm:fatal');
|
|
|
|
function normalizeError(errLike) {
|
|
if (!errLike) {
|
|
return { code: undefined, message: 'Unknown error', stack: undefined };
|
|
}
|
|
|
|
if (errLike instanceof Error) {
|
|
return {
|
|
code: errLike.code,
|
|
message: String(errLike.message || errLike),
|
|
stack: errLike.stack,
|
|
};
|
|
}
|
|
|
|
if (typeof errLike === 'string') {
|
|
return { code: undefined, message: errLike, stack: undefined };
|
|
}
|
|
|
|
// Promise rejection reasons can be arbitrary
|
|
try {
|
|
const message = String(errLike.message || errLike.toString?.() || errLike);
|
|
return { code: errLike.code, message, stack: errLike.stack };
|
|
} catch {
|
|
return { code: undefined, message: 'Unknown error', stack: undefined };
|
|
}
|
|
}
|
|
|
|
async function readLastReportSafe(filePath) {
|
|
try {
|
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
if (!raw) return null;
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
// Missing file is fine
|
|
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
|
|
// Corrupt JSON (or other read error): archive it once so we can proceed.
|
|
try {
|
|
const dir = path.dirname(filePath);
|
|
const base = path.basename(filePath);
|
|
const archived = path.join(dir, `${base}.corrupt.${Date.now()}`);
|
|
await fs.move(filePath, archived, { overwrite: false });
|
|
debug('Archived corrupt fatal report:', archived);
|
|
} catch (archiveErr) {
|
|
debug('Failed to archive corrupt fatal report:', archiveErr && (archiveErr.message || archiveErr));
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function atomicWriteJson(filePath, obj) {
|
|
const dir = path.dirname(filePath);
|
|
await fs.ensureDir(dir);
|
|
|
|
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
const payload = JSON.stringify(obj);
|
|
|
|
await fs.writeFile(tmpPath, payload, 'utf8');
|
|
// Atomic on same filesystem
|
|
await fs.rename(tmpPath, filePath);
|
|
}
|
|
|
|
function sameError(a, b) {
|
|
if (!a || !b) return false;
|
|
if (a.code && b.code && a.code === b.code) return true;
|
|
return a.message && b.message && a.message === b.message;
|
|
}
|
|
|
|
function parseWhen(when) {
|
|
if (!when) return null;
|
|
try {
|
|
const d = new Date(when);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return d;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function reportFatal(opts) {
|
|
const {
|
|
filePath,
|
|
kind,
|
|
error,
|
|
message,
|
|
throttleMs = 2 * 60 * 1000,
|
|
emailEnabled = false,
|
|
emailTo,
|
|
mailer,
|
|
} = opts || {};
|
|
|
|
if (!filePath) return;
|
|
|
|
try {
|
|
const normalized = normalizeError(error);
|
|
const now = new Date();
|
|
|
|
const last = await readLastReportSafe(filePath);
|
|
const lastWhen = parseWhen(last && last.when);
|
|
|
|
const current = {
|
|
code: normalized.code,
|
|
message: normalized.message,
|
|
when: now.toISOString(),
|
|
kind: kind || 'fatal',
|
|
};
|
|
|
|
if (lastWhen && sameError(last, current)) {
|
|
const delta = now.getTime() - lastWhen.getTime();
|
|
if (delta >= 0 && delta < throttleMs) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await atomicWriteJson(filePath, current);
|
|
|
|
if (emailEnabled && mailer && typeof mailer.sendTextMail === 'function') {
|
|
const subject = `[Agm-Errors] ${current.kind}`;
|
|
const body = message || (normalized.stack ? `${normalized.message}\n${normalized.stack}` : normalized.message);
|
|
await mailer.sendTextMail({ subject, text: body }, emailTo);
|
|
}
|
|
} catch (err) {
|
|
// Never throw from a fatal reporter
|
|
debug('Fatal reporter failed:', err && (err.message || err));
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
reportFatal,
|
|
};
|