feat(export): add SQL INSERT export crash diagnostics
This commit is contained in:
10 files changed
+298
-5
No files matched your search
@@ -12,6 +12,21 @@ interface DebugLogEntry {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BrowserMemorySnapshot {
|
||||
jsHeapUsedBytes?: number;
|
||||
jsHeapTotalBytes?: number;
|
||||
jsHeapLimitBytes?: number;
|
||||
deviceMemoryGiB?: number;
|
||||
hardwareConcurrency?: number;
|
||||
}
|
||||
|
||||
export interface NativeProcessMemorySnapshot {
|
||||
residentBytes: number;
|
||||
virtualBytes: number;
|
||||
totalMemoryBytes: number;
|
||||
usedMemoryBytes: number;
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
let originalConsole: Partial<Record<DebugLogLevel, (...args: unknown[]) => void>> = {};
|
||||
|
||||
@@ -115,6 +130,35 @@ export function appendDebugLog(level: DebugLogLevel, ...args: unknown[]) {
|
||||
safeLocalStorageSet(DEBUG_LOG_ENTRIES_KEY, JSON.stringify(entries.slice(-MAX_DEBUG_LOG_ENTRIES)));
|
||||
}
|
||||
|
||||
export function getBrowserMemorySnapshot(): BrowserMemorySnapshot {
|
||||
const snapshot: BrowserMemorySnapshot = {};
|
||||
const memory = (typeof performance !== "undefined" ? performance : undefined) as
|
||||
| (Performance & {
|
||||
memory?: { usedJSHeapSize?: number; totalJSHeapSize?: number; jsHeapSizeLimit?: number };
|
||||
})
|
||||
| undefined;
|
||||
if (memory?.memory?.usedJSHeapSize !== undefined) snapshot.jsHeapUsedBytes = memory.memory.usedJSHeapSize;
|
||||
if (memory?.memory?.totalJSHeapSize !== undefined) snapshot.jsHeapTotalBytes = memory.memory.totalJSHeapSize;
|
||||
if (memory?.memory?.jsHeapSizeLimit !== undefined) snapshot.jsHeapLimitBytes = memory.memory.jsHeapSizeLimit;
|
||||
if (typeof navigator !== "undefined") {
|
||||
const deviceMemory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
|
||||
if (deviceMemory !== undefined) snapshot.deviceMemoryGiB = deviceMemory;
|
||||
if (navigator.hardwareConcurrency) snapshot.hardwareConcurrency = navigator.hardwareConcurrency;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function appendNativeProcessMemoryLog(event: string, context: Record<string, unknown> = {}) {
|
||||
if (!isDebugLoggingEnabled() || !isTauriRuntimeLike()) return;
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const memory = await invoke<NativeProcessMemorySnapshot>("get_process_memory_info");
|
||||
appendDebugLog("info", `[DBX][native-memory:${event}]`, { ...context, ...memory });
|
||||
} catch (error) {
|
||||
appendDebugLog("warn", `[DBX][native-memory:${event}:error]`, { ...context, error });
|
||||
}
|
||||
}
|
||||
|
||||
export function setDebugLoggingEnabled(enabled: boolean) {
|
||||
safeLocalStorageSet(DEBUG_LOG_ENABLED_KEY, enabled ? "1" : "0");
|
||||
if (enabled) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeExportRows } from "@/lib/export/exportDiagnostics";
|
||||
|
||||
describe("summarizeExportRows", () => {
|
||||
it("reports shape, value kinds, and large-cell sizes without retaining values", () => {
|
||||
const large = "中".repeat(1024 * 1024);
|
||||
const summary = summarizeExportRows([[null, "ok", 1, true], [large]]);
|
||||
|
||||
expect(summary.rowCount).toBe(2);
|
||||
expect(summary.cellCount).toBe(5);
|
||||
expect(summary.nullCellCount).toBe(1);
|
||||
expect(summary.stringCellCount).toBe(2);
|
||||
expect(summary.numberCellCount).toBe(1);
|
||||
expect(summary.booleanCellCount).toBe(1);
|
||||
expect(summary.cellsAtLeast1MiB).toBe(1);
|
||||
expect(summary.rowsAtLeast1MiB).toBe(1);
|
||||
expect(summary.maxValueUtf8Bytes).toBe(3 * 1024 * 1024);
|
||||
expect(summary.minCellsPerRow).toBe(1);
|
||||
expect(summary.maxCellsPerRow).toBe(4);
|
||||
});
|
||||
|
||||
it("handles empty and ragged results", () => {
|
||||
expect(summarizeExportRows([])).toMatchObject({ rowCount: 0, cellCount: 0, minCellsPerRow: 0, maxCellsPerRow: 0 });
|
||||
expect(summarizeExportRows([[], ["x"]])).toMatchObject({ rowCount: 2, cellCount: 1, minCellsPerRow: 0, maxCellsPerRow: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
|
||||
export interface ExportRowsDiagnostics {
|
||||
rowCount: number;
|
||||
cellCount: number;
|
||||
nullCellCount: number;
|
||||
stringCellCount: number;
|
||||
numberCellCount: number;
|
||||
booleanCellCount: number;
|
||||
totalValueChars: number;
|
||||
totalValueUtf8Bytes: number;
|
||||
maxValueChars: number;
|
||||
maxValueUtf8Bytes: number;
|
||||
cellsAtLeast1MiB: number;
|
||||
cellsAtLeast10MiB: number;
|
||||
rowsAtLeast1MiB: number;
|
||||
minCellsPerRow: number;
|
||||
maxCellsPerRow: number;
|
||||
}
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
|
||||
function utf8ByteLength(value: string): number {
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x7f) bytes += 1;
|
||||
else if (code <= 0x7ff) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else bytes += 3;
|
||||
} else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function summarizeExportRows(rows: readonly (readonly CellValue[])[]): ExportRowsDiagnostics {
|
||||
let cellCount = 0;
|
||||
let nullCellCount = 0;
|
||||
let stringCellCount = 0;
|
||||
let numberCellCount = 0;
|
||||
let booleanCellCount = 0;
|
||||
let totalValueChars = 0;
|
||||
let totalValueUtf8Bytes = 0;
|
||||
let maxValueChars = 0;
|
||||
let maxValueUtf8Bytes = 0;
|
||||
let cellsAtLeast1MiB = 0;
|
||||
let cellsAtLeast10MiB = 0;
|
||||
let rowsAtLeast1MiB = 0;
|
||||
let minCellsPerRow = rows.length === 0 ? 0 : Number.POSITIVE_INFINITY;
|
||||
let maxCellsPerRow = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
minCellsPerRow = Math.min(minCellsPerRow, row.length);
|
||||
maxCellsPerRow = Math.max(maxCellsPerRow, row.length);
|
||||
let rowHasLargeCell = false;
|
||||
for (const value of row) {
|
||||
cellCount += 1;
|
||||
if (value === null) {
|
||||
nullCellCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string") stringCellCount += 1;
|
||||
else if (typeof value === "number") numberCellCount += 1;
|
||||
else booleanCellCount += 1;
|
||||
|
||||
const text = String(value);
|
||||
const utf8Bytes = utf8ByteLength(text);
|
||||
totalValueChars += text.length;
|
||||
totalValueUtf8Bytes += utf8Bytes;
|
||||
maxValueChars = Math.max(maxValueChars, text.length);
|
||||
maxValueUtf8Bytes = Math.max(maxValueUtf8Bytes, utf8Bytes);
|
||||
if (utf8Bytes >= MEBIBYTE) {
|
||||
cellsAtLeast1MiB += 1;
|
||||
rowHasLargeCell = true;
|
||||
}
|
||||
if (utf8Bytes >= 10 * MEBIBYTE) cellsAtLeast10MiB += 1;
|
||||
}
|
||||
if (rowHasLargeCell) rowsAtLeast1MiB += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
rowCount: rows.length,
|
||||
cellCount,
|
||||
nullCellCount,
|
||||
stringCellCount,
|
||||
numberCellCount,
|
||||
booleanCellCount,
|
||||
totalValueChars,
|
||||
totalValueUtf8Bytes,
|
||||
maxValueChars,
|
||||
maxValueUtf8Bytes,
|
||||
cellsAtLeast1MiB,
|
||||
cellsAtLeast10MiB,
|
||||
rowsAtLeast1MiB,
|
||||
minCellsPerRow: Number.isFinite(minCellsPerRow) ? minCellsPerRow : 0,
|
||||
maxCellsPerRow,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { appendDebugLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
|
||||
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string, diagnostics: { exportId?: string; operation?: string } = {}) {
|
||||
const logSaveStage = (stage: string, details: Record<string, unknown> = {}) => {
|
||||
if (!isDebugLoggingEnabled()) return;
|
||||
appendDebugLog("info", `[DBX][export:save:${stage}]`, {
|
||||
...diagnostics,
|
||||
filterName,
|
||||
filterExt,
|
||||
contentChars: content.length,
|
||||
...details,
|
||||
browserMemory: getBrowserMemorySnapshot(),
|
||||
});
|
||||
};
|
||||
|
||||
logSaveStage("start");
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
@@ -8,7 +22,9 @@ export async function saveTextFile(content: string, defaultFileName: string, fil
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
logSaveStage("dialog-result", { selected: !!path });
|
||||
if (path) await writeTextFile(path, content);
|
||||
if (path) logSaveStage("write-done");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,6 +35,7 @@ export async function saveTextFile(content: string, defaultFileName: string, fil
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
logSaveStage("browser-download-triggered");
|
||||
}
|
||||
|
||||
export function sanitizeExportBaseName(value: string): string {
|
||||
|
||||
Reference in new issue
Block a user