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.
Top comments (0)