mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-18 05:10:00 +08:00
feat # 新增日志功能
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
byteLength, cleanText, extensionError, extensionSource, serializeError, snapshot,
|
||||
} from "./serialize.js";
|
||||
import { createDiagnosticStorage, isDiagnosticFile, LOG_DIRECTORY } from "./storage.js";
|
||||
|
||||
const noop = () => null;
|
||||
const inactive = Object.freeze({
|
||||
info: noop, warn: noop, error: noop, scope: () => inactive,
|
||||
registerContext: () => noop, flush: async () => {},
|
||||
step: async (_event, action) => action(),
|
||||
status: () => ({ enabled: false }), dispose: noop,
|
||||
});
|
||||
let current = inactive;
|
||||
|
||||
export function getDiagnostics() { return current; }
|
||||
|
||||
export function createDiagnostics(options = {}) {
|
||||
const env = options.env || {};
|
||||
const now = options.now || Date.now;
|
||||
const monotonic = () => {
|
||||
try { return env.performance?.now?.() ?? now(); } catch { return now(); }
|
||||
};
|
||||
const launchId = `${now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const maxMemoryBytes = options.maxMemoryBytes ?? 512 * 1024;
|
||||
const maxJournalBytes = options.maxJournalBytes ?? 256 * 1024;
|
||||
const maxReportBytes = options.maxReportBytes ?? 512 * 1024;
|
||||
const maxDiskBytes = options.maxDiskBytes ?? 5 * 1024 * 1024;
|
||||
const maxFiles = options.maxFiles ?? 64;
|
||||
const maxPending = options.maxPending ?? 12;
|
||||
const flushMs = options.flushMs ?? 1000;
|
||||
const recent = [], contexts = new Map(), pending = new Map();
|
||||
const errors = new WeakMap(), fingerprints = new Map(), listeners = [];
|
||||
let sequence = 0, reportSequence = 0, segment = 0, memoryBytes = 0;
|
||||
let journal = [], journalBytes = 0, revision = 0, queuedRevision = -1;
|
||||
let flushTimer = null, worker = null, stopped = false, unavailable = false;
|
||||
let latestReport = null, droppedRecords = 0, droppedWrites = 0;
|
||||
let inventory = null, inventoryKind = null;
|
||||
const metadata = snapshot({
|
||||
extension: "术樱包", extensionVersion: options.version || "unknown",
|
||||
engineVersion: env.lib?.version || "unknown", launchId,
|
||||
platform: env.lib?.node ? "pc" : env.lib?.device ? "android" : "browser",
|
||||
userAgent: env.window?.navigator?.userAgent || "unknown",
|
||||
modules: { mengsan: env.lib?.config?.shuYing_mengsan_installed_version || null },
|
||||
});
|
||||
const roots = [];
|
||||
try {
|
||||
const root = new URL("../", import.meta.url);
|
||||
if (["file:", "http:", "https:"].includes(root.protocol)) roots.push(root.href);
|
||||
} catch {}
|
||||
if (options.extensionRoot) {
|
||||
try {
|
||||
roots.push(new URL(options.extensionRoot.replace(/\/?$/, "/"),
|
||||
env.document?.baseURI || env.window?.location?.href).href);
|
||||
} catch {}
|
||||
}
|
||||
const storage = options.storage || createDiagnosticStorage(env, {
|
||||
timeoutMs: options.storageTimeoutMs,
|
||||
onFailure(error, phase) {
|
||||
record("WARN", "diagnostics.storage.failure", { phase }, error, "diagnostics");
|
||||
},
|
||||
});
|
||||
const storageStatus = () => storage.status?.() || { kind: "file", location: LOG_DIRECTORY };
|
||||
const runtimeName = () => `runtime-${launchId}-${segment}.jsonl`;
|
||||
|
||||
function contextFor(extra) {
|
||||
const values = {};
|
||||
for (const [name, provider] of contexts) {
|
||||
try { values[name] = snapshot(provider()); }
|
||||
catch { values[name] = { diagnosticContextUnavailable: true }; }
|
||||
}
|
||||
return { ...values, ...snapshot(extra || {}) };
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (flushTimer || stopped || unavailable) return;
|
||||
flushTimer = setTimeout(() => { flushTimer = null; void flush(); }, flushMs);
|
||||
flushTimer?.unref?.();
|
||||
}
|
||||
|
||||
function record(level, event, extra, error, module = "extension") {
|
||||
if (stopped) return null;
|
||||
try {
|
||||
const entry = {
|
||||
schemaVersion: 1, sequence: ++sequence, time: new Date(now()).toISOString(),
|
||||
launchId, level, module: cleanText(module, 128), event: cleanText(event, 256),
|
||||
context: contextFor(extra),
|
||||
};
|
||||
if (error !== undefined) entry.error = serializeError(error);
|
||||
const item = { entry, bytes: 0 };
|
||||
recent.push(item);
|
||||
if (level === "ERROR") createReport(entry, error);
|
||||
const text = JSON.stringify(entry) + "\n";
|
||||
const bytes = byteLength(text);
|
||||
item.bytes = bytes; memoryBytes += bytes;
|
||||
if (journalBytes + bytes > maxJournalBytes && journal.length) {
|
||||
enqueue(runtimeName(), journal.join(""));
|
||||
segment++; journal = []; journalBytes = 0; queuedRevision = -1;
|
||||
}
|
||||
journal.push(text); journalBytes += bytes; revision++;
|
||||
while (recent.length > 400 || memoryBytes > maxMemoryBytes) {
|
||||
memoryBytes -= recent.shift().bytes; droppedRecords++;
|
||||
}
|
||||
schedule();
|
||||
if (level === "ERROR") void flush();
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createReport(entry, rawError) {
|
||||
let known;
|
||||
if (rawError && typeof rawError === "object") known = errors.get(rawError);
|
||||
const fingerprint = `${entry.module}:${entry.event}:${entry.error?.name}:${entry.error?.message}`;
|
||||
if (!known) {
|
||||
const prior = fingerprints.get(fingerprint);
|
||||
if (prior && now() - prior.time < 5000) known = prior;
|
||||
}
|
||||
if (!known) known = { id: `${launchId}-${++reportSequence}`, count: 0,
|
||||
first: entry.time, time: now(), firstFailure: entry };
|
||||
known.count++; known.time = now();
|
||||
if (rawError && typeof rawError === "object") errors.set(rawError, known);
|
||||
fingerprints.set(fingerprint, known);
|
||||
while (fingerprints.size > 64) fingerprints.delete(fingerprints.keys().next().value);
|
||||
entry.reportId = known.id;
|
||||
const file = `report-${known.id}.json`;
|
||||
const report = {
|
||||
schemaVersion: 1, reportId: known.id, firstSeen: known.first,
|
||||
lastSeen: entry.time, occurrences: known.count, environment: metadata,
|
||||
storageAtCapture: snapshot(storageStatus()), firstFailure: known.firstFailure,
|
||||
failure: entry, records: recent.map(item => item.entry),
|
||||
droppedRecords, droppedWrites,
|
||||
};
|
||||
let text = JSON.stringify(report, null, 2);
|
||||
while (byteLength(text) > maxReportBytes && report.records.length > 1) {
|
||||
report.records.shift(); report.droppedRecords++;
|
||||
text = JSON.stringify(report, null, 2);
|
||||
}
|
||||
latestReport = { id: known.id, file, revision: known.count,
|
||||
persisted: false, event: entry.event };
|
||||
enqueue(file, text, true, known.count);
|
||||
}
|
||||
|
||||
function enqueue(name, text, priority = false, reportRevision = null) {
|
||||
if (unavailable || stopped) return;
|
||||
if (!pending.has(name) && pending.size >= maxPending) {
|
||||
const victim = [...pending].find(([, item]) => !item.priority)?.[0];
|
||||
if (!victim) { droppedWrites++; return; }
|
||||
pending.delete(victim); droppedWrites++;
|
||||
}
|
||||
pending.set(name, { text, priority, reportRevision });
|
||||
startWorker();
|
||||
}
|
||||
|
||||
async function prune(protect, text) {
|
||||
const kind = storageStatus().kind;
|
||||
if (!inventory || inventoryKind !== kind) {
|
||||
inventory = new Map((await storage.list()).filter(file =>
|
||||
isDiagnosticFile(file.name)).map(file => [file.name, file]));
|
||||
inventoryKind = kind;
|
||||
}
|
||||
inventory.set(protect, { name: protect, bytes: byteLength(text), time: now() });
|
||||
const files = [...inventory.values()];
|
||||
files.sort((a, b) => (a.time || 0) - (b.time || 0));
|
||||
let bytes = files.reduce((sum, file) => sum + (file.bytes || 0), 0);
|
||||
let count = files.length;
|
||||
for (const file of files) {
|
||||
if (bytes <= maxDiskBytes && count <= maxFiles) break;
|
||||
if (file.name === protect || file.name === runtimeName()
|
||||
|| file.name === latestReport?.file) continue;
|
||||
await storage.remove(file.name);
|
||||
inventory.delete(file.name);
|
||||
bytes -= file.bytes || 0; count--;
|
||||
}
|
||||
}
|
||||
|
||||
function startWorker() {
|
||||
if (worker || !pending.size) return;
|
||||
worker = Promise.resolve().then(async () => {
|
||||
while (pending.size && !unavailable) {
|
||||
const name = [...pending].find(([, item]) => item.priority)?.[0]
|
||||
|| pending.keys().next().value;
|
||||
const item = pending.get(name);
|
||||
pending.delete(name);
|
||||
try {
|
||||
await storage.write(name, item.text);
|
||||
if (latestReport?.file === name && latestReport.revision === item.reportRevision) {
|
||||
latestReport.persisted = true;
|
||||
latestReport.savedStorage = snapshot(storageStatus());
|
||||
}
|
||||
try { await prune(name, item.text); } catch { inventory = null; }
|
||||
} catch {
|
||||
unavailable = true; pending.clear();
|
||||
}
|
||||
}
|
||||
}).catch(() => { unavailable = true; pending.clear(); }).finally(() => {
|
||||
worker = null;
|
||||
if (pending.size && !unavailable) startWorker();
|
||||
});
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
try {
|
||||
clearTimeout(flushTimer); flushTimer = null;
|
||||
if (journal.length && queuedRevision !== revision) {
|
||||
queuedRevision = revision;
|
||||
enqueue(runtimeName(), journal.join(""));
|
||||
}
|
||||
while (worker) await worker;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function scope(module, provider = () => ({})) {
|
||||
const details = extra => {
|
||||
try {
|
||||
const context = typeof provider === "function" ? provider() : provider;
|
||||
return { ...snapshot(context), ...snapshot(extra || {}) };
|
||||
} catch { return { diagnosticContextUnavailable: true }; }
|
||||
};
|
||||
return Object.freeze({
|
||||
info: (event, extra) => record("INFO", event, details(extra), undefined, module),
|
||||
warn: (event, extra, error) => record("WARN", event, details(extra), error, module),
|
||||
error: (event, error, extra) => record("ERROR", event, details(extra), error, module),
|
||||
async step(event, action, extra = {}, options = {}) {
|
||||
const started = monotonic();
|
||||
record("INFO", `${event}.start`, details(extra), undefined, module);
|
||||
let timer;
|
||||
if (options.observeWait !== false) {
|
||||
timer = setTimeout(() => {
|
||||
if (env.document?.hidden) return;
|
||||
record("WARN", `${event}.waiting`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), undefined, module);
|
||||
}, options.waitMs ?? 15000);
|
||||
timer?.unref?.();
|
||||
}
|
||||
try {
|
||||
const result = await action();
|
||||
record("INFO", `${event}.complete`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), undefined, module);
|
||||
return result;
|
||||
} catch (error) {
|
||||
record("ERROR", `${event}.failed`, details({ ...extra,
|
||||
elapsedMs: monotonic() - started }), error, module);
|
||||
throw error;
|
||||
} finally { clearTimeout(timer); }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function listen(target, event, handler, capture = false) {
|
||||
if (typeof target?.addEventListener !== "function") return;
|
||||
target.addEventListener(event, handler, capture);
|
||||
listeners.push(() => target.removeEventListener(event, handler, capture));
|
||||
}
|
||||
const globalLogger = scope("extension.global");
|
||||
const globalSeen = new WeakMap();
|
||||
function captureGlobal(error, source, extra = {}) {
|
||||
if (!extensionSource(source, roots) && !extensionError(error, roots)) return;
|
||||
if (error && typeof error === "object") {
|
||||
const last = globalSeen.get(error);
|
||||
if (last !== undefined && now() - last < 20) return;
|
||||
globalSeen.set(error, now());
|
||||
}
|
||||
globalLogger.error("uncaught", error, { source, ...extra });
|
||||
}
|
||||
listen(env.window, "error", event => {
|
||||
try {
|
||||
if (extensionSource(event.filename, roots) || extensionError(event.error, roots)) {
|
||||
captureGlobal(event.error || event.message, event.filename,
|
||||
{ line: event.lineno, column: event.colno });
|
||||
} else {
|
||||
const source = event.target?.src || event.target?.href;
|
||||
if (extensionSource(source, roots)) globalLogger.error("resource.load",
|
||||
new Error("术樱包资源加载失败"), { source });
|
||||
}
|
||||
} catch {}
|
||||
}, true);
|
||||
listen(env.window, "unhandledrejection", event => {
|
||||
try {
|
||||
if (extensionError(event.reason, roots)) captureGlobal(event.reason, "",
|
||||
{ origin: "unhandledrejection" });
|
||||
} catch {}
|
||||
});
|
||||
if (env.window) {
|
||||
const previous = env.window.onerror;
|
||||
const chained = function(message, source, line, column, error) {
|
||||
try { captureGlobal(error || message, source, { line, column }); } catch {}
|
||||
return typeof previous === "function" ? previous.apply(this, arguments) : undefined;
|
||||
};
|
||||
env.window.onerror = chained;
|
||||
listeners.push(() => {
|
||||
if (env.window.onerror === chained) env.window.onerror = previous;
|
||||
});
|
||||
}
|
||||
listen(env.document, "visibilitychange", () => {
|
||||
if (env.document.hidden) void flush();
|
||||
});
|
||||
listen(env.window, "pagehide", () => { void flush(); });
|
||||
const api = Object.freeze({
|
||||
...scope("extension"), scope,
|
||||
registerContext(name, provider) {
|
||||
contexts.set(name, provider);
|
||||
return () => contexts.delete(name);
|
||||
},
|
||||
flush,
|
||||
status: () => snapshot({ enabled: true, ...storageStatus(),
|
||||
unavailable, latestReport, droppedRecords, droppedWrites,
|
||||
runtimeFile: runtimeName(), launchId }),
|
||||
dispose() {
|
||||
void flush(); stopped = true; clearTimeout(flushTimer);
|
||||
for (const remove of listeners) remove();
|
||||
if (current === api) current = inactive;
|
||||
},
|
||||
});
|
||||
api.info("diagnostics.started", { environment: metadata });
|
||||
return api;
|
||||
}
|
||||
|
||||
export function configureDiagnostics({ lib, game, window, document, version, extensionRoot }) {
|
||||
if (current !== inactive) return current;
|
||||
try {
|
||||
current = createDiagnostics({ version, extensionRoot,
|
||||
env: { lib, game, window, document, performance: window?.performance,
|
||||
localStorage: window?.localStorage, indexedDB: window?.indexedDB } });
|
||||
} catch {}
|
||||
return current;
|
||||
}
|
||||
Reference in New Issue
Block a user