diff --git a/Cargo.lock b/Cargo.lock index 52ee99c7c5..d8da19ec63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2035,6 +2035,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sys-locale", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 9876ef62dc..06998c5da0 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -16,6 +16,8 @@ import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib import { buildDataGridCopyInsertStatement, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql"; import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats"; import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode"; +import { summarizeExportRows } from "@/lib/export/exportDiagnostics"; +import { appendDebugLog, appendNativeProcessMemoryLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { uuid } from "@/lib/common/utils"; import { useSettingsStore } from "@/stores/settingsStore"; import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue"; @@ -1334,18 +1336,76 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function exportSql(rowIds?: number[]) { await runExclusiveExport(async () => { + const exportId = uuid(); + const exportStartedAt = performance.now(); + const logExportStage = (stage: string, details: Record = {}, sampleNativeMemory = false) => { + if (!isDebugLoggingEnabled()) return; + appendDebugLog("info", `[DBX][export:sql:${stage}]`, { + exportId, + elapsedMs: Math.round(performance.now() - exportStartedAt), + ...details, + browserMemory: getBrowserMemorySnapshot(), + }); + if (sampleNativeMemory) void appendNativeProcessMemoryLog(`export-sql-${stage}`, { exportId }); + }; + + logExportStage( + "start", + { + context: context.value, + databaseType: databaseType.value, + exportAllRows: rowIds === undefined, + requestedRowCount: rowIds?.length ?? null, + hasCompleteLocalResult: hasCompleteLocalResult?.value ?? null, + }, + true, + ); const insertMode = await showSqlInsertModeDialog(); - if (insertMode === null) return; + if (insertMode === null) { + logExportStage("cancelled", { stage: "insert-mode-dialog" }); + return; + } + logExportStage("mode-selected", { insertMode }); try { // Step 1: table-data context β€” existing backend table export - if (await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode)) return; + logExportStage("backend-export-start"); + const handledByBackend = await exportFullTableDataViaBackend("sql", rowIds, "name", true, insertMode); + logExportStage("backend-export-finished", { handledByBackend }); + if (handledByBackend) { + logExportStage("done", { path: "backend" }); + return; + } // Step 2: query-result context β€” NEW backend streaming with background task - if (await exportQueryResultSqlViaBackend(rowIds, insertMode)) return; + logExportStage("query-backend-export-start"); + const handledQueryByBackend = await exportQueryResultSqlViaBackend(rowIds, insertMode); + logExportStage("query-backend-export-finished", { handledByBackend: handledQueryByBackend }); + if (handledQueryByBackend) { + logExportStage("done", { path: "query-backend" }); + return; + } // Step 3: fallback β€” local export (Web and edge-case scenarios) const result = await resultToExport(rowIds, undefined, true, false); + logExportStage( + "result-ready", + { + columns: result.columns.length, + rows: result.rows.length, + values: isDebugLoggingEnabled() ? summarizeExportRows(result.rows) : undefined, + }, + true, + ); + + logExportStage("row-remap-start"); const exportData = sqlInsertExportData(result); + logExportStage("row-remap-done", { + columns: exportData.columns.length, + rows: exportData.rows.length, + values: isDebugLoggingEnabled() ? summarizeExportRows(exportData.rows) : undefined, + }); + + logExportStage("sql-build-start"); const content = await formatSqlInsert({ databaseType: databaseType.value, identifierQuote: options.identifierQuote?.value, @@ -1358,10 +1418,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { rows: exportData.rows, insertMode, }); - await saveTextFile(content, exportFileName(tableMeta.value?.tableName || "export", "sql", { preferFallback: true }), "SQL", "sql"); + logExportStage( + "sql-build-done", + { + sqlChars: content.length, + }, + true, + ); + logExportStage("save-start", { contentChars: content.length }, true); + await saveTextFile(content, exportFileName(tableMeta.value?.tableName || "export", "sql", { preferFallback: true }), "SQL", "sql", { exportId, operation: "sql-insert-all" }); + logExportStage("done", { contentChars: content.length }); toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: translateBackendError(t, e) }), 5000); + logExportStage("error", { + errorName: e?.name || typeof e, + errorMessage: e?.message || String(e), + }); } }); } diff --git a/apps/desktop/src/lib/backend/debugLog.ts b/apps/desktop/src/lib/backend/debugLog.ts index 20bd7a507a..f720f20d0a 100644 --- a/apps/desktop/src/lib/backend/debugLog.ts +++ b/apps/desktop/src/lib/backend/debugLog.ts @@ -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 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 = {}) { + if (!isDebugLoggingEnabled() || !isTauriRuntimeLike()) return; + try { + const { invoke } = await import("@tauri-apps/api/core"); + const memory = await invoke("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) { diff --git a/apps/desktop/src/lib/export/__tests__/exportDiagnostics.spec.ts b/apps/desktop/src/lib/export/__tests__/exportDiagnostics.spec.ts new file mode 100644 index 0000000000..f6ba42d325 --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/exportDiagnostics.spec.ts @@ -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 }); + }); +}); diff --git a/apps/desktop/src/lib/export/exportDiagnostics.ts b/apps/desktop/src/lib/export/exportDiagnostics.ts new file mode 100644 index 0000000000..5096706de5 --- /dev/null +++ b/apps/desktop/src/lib/export/exportDiagnostics.ts @@ -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, + }; +} diff --git a/apps/desktop/src/lib/export/saveTextFile.ts b/apps/desktop/src/lib/export/saveTextFile.ts index 75634a675d..8d59bfca3e 100644 --- a/apps/desktop/src/lib/export/saveTextFile.ts +++ b/apps/desktop/src/lib/export/saveTextFile.ts @@ -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 = {}) => { + 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 { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cd29ea5fce..402f4b3044 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,6 +73,7 @@ axum = { version = "0.8", features = ["ws"] } font-kit = "0.14.3" tauri-plugin-clipboard-manager = "2.3.2" sys-locale = "0.3" +sysinfo = { version = "0.32", features = ["system"] } [dev-dependencies] tauri = { version = "2.10.3", features = ["test"] } diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs new file mode 100644 index 0000000000..2be60bfe43 --- /dev/null +++ b/src-tauri/src/commands/diagnostics.rs @@ -0,0 +1,27 @@ +use serde::Serialize; +use sysinfo::{MemoryRefreshKind, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessMemoryInfo { + pub resident_bytes: u64, + pub virtual_bytes: u64, + pub total_memory_bytes: u64, + pub used_memory_bytes: u64, +} + +#[tauri::command] +pub fn get_process_memory_info() -> ProcessMemoryInfo { + let pid = sysinfo::Pid::from_u32(std::process::id()); + let process_refresh = ProcessRefreshKind::new().with_memory(); + let mut system = System::new_with_specifics(RefreshKind::new().with_memory(MemoryRefreshKind::new())); + system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, process_refresh); + let process = system.process(pid); + + ProcessMemoryInfo { + resident_bytes: process.map(|value| value.memory()).unwrap_or_default(), + virtual_bytes: process.map(|value| value.virtual_memory()).unwrap_or_default(), + total_memory_bytes: system.total_memory(), + used_memory_bytes: system.used_memory(), + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 816ea2b096..fbe40d017d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod csv_export; pub mod data_compare; pub mod database_export; pub mod deep_link; +pub mod diagnostics; pub mod dialect_cmd; pub mod docs; pub mod document_cmd; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 211906b3cc..d2637b9114 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1754,6 +1754,7 @@ pub fn run() { commands::app_settings::load_transfer_task_library, commands::app_settings::save_transfer_task_library, commands::app_settings::load_native_debug_logs, + commands::diagnostics::get_process_memory_info, commands::support_info::get_app_support_info, commands::cloud_sync::webdav_sync_test, commands::cloud_sync::webdav_password_status,