mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-11 03:20:01 +08:00
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
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 maxPendingBytes = options.maxPendingBytes ?? 2 * 1024 * 1024;
|
|
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;
|
|
let pendingBytes = 0;
|
|
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, text: "" };
|
|
recent.push(item);
|
|
if (level === "ERROR") createReport(entry, error);
|
|
const text = JSON.stringify(entry);
|
|
const bytes = byteLength(text) + 1;
|
|
item.text = text;
|
|
delete item.entry;
|
|
item.bytes = bytes; memoryBytes += bytes;
|
|
if (journalBytes + bytes > maxJournalBytes && journal.length) {
|
|
enqueue(runtimeName(), journal.join(""));
|
|
segment++; journal = []; journalBytes = 0; queuedRevision = -1;
|
|
}
|
|
journal.push(text + "\n"); 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,
|
|
droppedRecords, droppedWrites,
|
|
};
|
|
const records = recent.map(item => {
|
|
const text = item.text || JSON.stringify(item.entry);
|
|
return { text, bytes: item.text ? item.bytes - 1 : byteLength(text) };
|
|
});
|
|
let start = 0;
|
|
let bytes = byteLength(JSON.stringify(report)) + 14
|
|
+ records.reduce((total, item) => total + item.bytes + 1, 0);
|
|
while (bytes + 32 > maxReportBytes && start < records.length - 1) {
|
|
bytes -= records[start++].bytes + 1;
|
|
}
|
|
report.droppedRecords += start;
|
|
const text = JSON.stringify(report).slice(0, -1) + ',"records":['
|
|
+ records.slice(start).map(item => item.text).join(",") + "]}";
|
|
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;
|
|
const bytes = byteLength(text);
|
|
const previous = pending.get(name);
|
|
if (previous) { pendingBytes -= previous.bytes; pending.delete(name); }
|
|
if (bytes > maxPendingBytes) { droppedWrites++; return; }
|
|
while (pending.size >= maxPending || pendingBytes + bytes > maxPendingBytes) {
|
|
const victim = [...pending].find(([, item]) => !item.priority)?.[0]
|
|
|| (priority ? pending.keys().next().value : null);
|
|
if (!victim) { droppedWrites++; return; }
|
|
pendingBytes -= pending.get(victim).bytes;
|
|
pending.delete(victim); droppedWrites++;
|
|
}
|
|
pending.set(name, { text, bytes, priority, reportRevision });
|
|
pendingBytes += bytes;
|
|
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);
|
|
pendingBytes -= item.bytes;
|
|
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(); pendingBytes = 0;
|
|
}
|
|
}
|
|
}).catch(() => { unavailable = true; pending.clear(); pendingBytes = 0; }).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,
|
|
pendingFiles: pending.size, pendingBytes,
|
|
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;
|
|
}
|