Electron Logging and Crash Reporting: A Production Guide to Capturing What Your App Did Wrong
Electron Logging and Crash Reporting: A Production Guide to Capturing What Your App Did Wrong
TL;DR: In an Electron app,
console.logdisappears the moment you package it. Production debugging needs three separate trails — structured logs in the main process, a controlled IPC bridge from renderers, and a native crash reporter — plus a way to redact secrets and ship a bundle to support. This guide gives you a setup that works on Windows, macOS, and Linux without leaking user data.
When a user says "your app froze at 4pm and showed a white screen," what do you actually know? Without logging, the answer is "nothing." Browser-side devtools are gone in production, the main process stdout is captured by nobody, and a native crash leaves a .dmp file sitting on a machine you will never touch.
This is the setup we wish we had on day one.
Table of Contents
- Why
console.logis not enough - Layer 1: Structured main-process logs
- Layer 2: Getting renderer logs to disk
- Layer 3: Catching native and renderer crashes
- Privacy: redact before you write
- One-click "send me the logs"
- Common pitfalls
- Production checklist
Why console.log is not enough
| Channel | Where it goes in dev | Where it goes in production |
|---|---|---|
Renderer console.log | DevTools console | Nowhere — DevTools is detached, stdout is lost |
Main console.log | Terminal / VS Code | Captured by the OS only if launched from a terminal |
| Native crash | DevTools + terminal | A *.dmp file in userData that no one reads |
| Renderer unhandled promise | DevTools warning | Silent failure, white screen, user force-quits |
The fix is to treat logging as infrastructure: a single, rotating, on-disk trail the main process owns, plus a crash reporter that writes or uploads dumps.
Layer 1: Structured main-process logs
Use electron-log (the de-facto standard) rather than rolling your own. It handles transports, rotation, and file paths per-platform out of the box.
// main/log.js const { app } = require('electron'); const log = require('electron-log'); log.transports.file.resolvePathFn = () => `${app.getPath('userData')}/logs/main.log`; // Keep the file small; rotate automatically. log.transports.file.maxSize = 2 * 1024 * 1024; // 2 MB before rotation log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'; // In production, silence console noise; keep file. log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn'; log.transports.file.level = 'info'; module.exports = log; Why this shape:
-
userData/logs/is the correct location. It is per-user, writable, and survives updates. Never write to the app bundle directory — on macOS it is read-only inside the.app, and on Windows Program Files needs admin rights. - Rotation matters. A log that grows forever becomes a support ticket of its own. Two megabytes is plenty;
electron-logkeeps amain.old.logalongside it. - JSON or text? Text is fine for humans; switch to
jsonformat when you ship logs into a central store.
Layer 2: Getting renderer logs to disk
The renderer cannot write files safely. It must send its logs over IPC to the main process, which is the only writer.
// preload.js — expose a safe, minimal API, not ipcRenderer itself const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('appLogger', { info: (msg, meta) => ipcRenderer.send('log', { level: 'info', msg, meta }), warn: (msg, meta) => ipcRenderer.send('log', { level: 'warn', msg, meta }), error: (msg, meta) => ipcRenderer.send('log', { level: 'error', msg, meta }), }); // main/index.js const log = require('./log'); const { ipcMain } = require('electron'); ipcMain.on('log', (event, entry) => { // Validate shape before writing — never trust renderer input. if (!entry || typeof entry.msg !== 'string') return; const meta = entry.meta ? JSON.stringify(entry.meta) : ''; log[entry.level]?.(`[renderer] ${entry.msg} ${meta}`); }); Two rules that prevent this channel from becoming a security hole:
- Validate and whitelist. A renderer can be compromised by malicious content; treat everything on
logas untrusted data, not instructions. - Don't expose
ipcRenderer.contextBridgegives the renderer exactly three functions and nothing else. This is the same least-privilege boundary you should already be applying to every IPC channel.
You can also wire renderer-level global errors in one place:
window.addEventListener('error', (e) => { window.appLogger.error('uncaught error', { message: e.message, stack: e.error?.stack }); }); window.addEventListener('unhandledrejection', (e) => { window.appLogger.error('unhandled rejection', { reason: String(e.reason) }); }); Layer 3: Catching native and renderer crashes
Native crashes (a segfault in a native module, a Chromium crash) need Electron's built-in crash reporter. It writes minidumps and can upload them.
const { crashReporter } = require('electron'); crashReporter.start({ productName: app.getName(), companyName: 'YourCompany', submitURL: 'https://logs.example.com/crash', // or '' for local-only uploadToServer: false, // flip to true once you have an endpoint ignoreSystemCrashHandler: true, }); While submitURL is empty, dumps land in userData/Crashpad/ and you can retrieve them on demand. Set uploadToServer: true only when you control the endpoint and have a privacy review (see next section).
On the main side, also catch the process-level failures that would otherwise vanish:
process.on('uncaughtException', (err) => { log.error('main uncaughtException', { error: err.stack }); // Give the logger a tick to flush before exiting. setTimeout(() => app.exit(1), 500); }); process.on('unhandledRejection', (reason) => { log.error('main unhandledRejection', { reason: String(reason) }); }); Privacy: redact before you write
Logs are a privacy surface. Users do not expect passwords, tokens, API keys, emails, or file paths from their documents to be uploaded to your server.
// redact.js — a small middleware applied to every logged object. const SECRET = /(Bearer\s+[A-Za-z0-9\-._~+\/]+|eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+|password|token|api[_-]?key)/gi; function redact(input) { if (typeof input === 'string') return input.replace(SECRET, '[REDACTED]'); if (typeof input !== 'object' || input === null) return input; const out = {}; for (const [k, v] of Object.entries(input)) { out[k] = /password|token|secret|key|cookie/i.test(k) ? '[REDACTED]' : redact(v); } return out; } module.exports = { redact }; Apply it at the boundary — before the IPC handler writes, and before crashReporter uploads — not inside every call site. It is much easier to forget one call site than to forget one middleware.
One-click "send me the logs"
The highest ROI feature for a support team: a menu item that bundles the current logs and a few pieces of metadata into a zip, and lets the user attach it to a bug report.
// main/collect-logs.js const { app, dialog } = require('electron'); const fs = require('fs'); const path = require('path'); const AdmZip = require('adm-zip'); async function collectLogs() { const dir = app.getPath('userData'); const zip = new AdmZip(); const logsDir = path.join(dir, 'logs'); if (fs.existsSync(logsDir)) zip.addLocalFolder(logsDir, 'logs'); const meta = { appVersion: app.getVersion(), electron: process.versions.electron, platform: process.platform, arch: process.arch, date: new Date().toISOString(), }; zip.addFile('meta.json', Buffer.from(JSON.stringify(meta, null, 2))); const out = path.join(app.getPath('downloads'), `app-logs-${Date.now()}.zip`); zip.writeZip(out); await dialog.showMessageBox({ type: 'info', message: 'Logs saved', detail: `Saved to:\n${out}\nPlease attach this to your bug report.`, }); } This is intentionally user-initiated: the user chooses when to send what. That is both a privacy win and a support win — people trust the app more when logs are visible and opt-in.
Common pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
| Writing logs next to the app bundle | Read-only on macOS / Program Files on Windows | Use app.getPath('userData') |
| Uploading renderer logs straight to a server | PII and tokens leak | Redact at the IPC boundary, then upload |
crashReporter.start before app.ready | Dumps are not captured reliably | Start it early, after the module graph loads |
| Logging every IPC message | File grows to hundreds of MB | Sample noisy channels; keep debug level local-only |
| Forgetting to flush before exit | Last log line is missing on crash | setTimeout(() => app.exit(1), 500) after uncaughtException |
Trusting renderer meta | A malicious renderer can forge fields | Whitelist meta keys, stringify everything |
Production checklist
- [ ] Main process writes rotating logs under
userData/logs/with a max size. - [ ] Renderer logs go through a
contextBridgeIPC channel, never rawipcRenderer. - [ ] IPC handler validates message shape and redacts secrets before writing.
- [ ]
crashReporter.start()runs early, with local dumps until you are ready to upload. - [ ] Main process handles
uncaughtExceptionandunhandledRejection, with a flush before exit. - [ ] A user-facing "send logs" action zips
logs/+meta.jsonto the Downloads folder. - [ ] File logging level is
infoin production,debugin development. - [ ] No secret, token, or email pattern appears in a sample log line you review before shipping.
Good logging is invisible until the day you need it. Build it on a quiet Tuesday, because on a Friday night at 9pm you will be very glad it is there.