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 (2)
In the IPC handler,
entry.metais stringified directly, soappLogger.info('login failed', { password: 'example-only' })can write that value to the log. It would help to showredact({ msg: entry.msg, meta: entry.meta })in that handler before formatting the log line. The privacy section already recommends redacting at the boundary, but the copyable handler doesn't call the helper.You're right, that's a real gap in the copyable handler — stringifying
entry.metabefore any sanitization means anything in the payload lands verbatim in the log line. The fix is to run the redact helper at the boundary in the handler itself, passing bothentry.msgandentry.metathrough it before the line is formatted or persisted, rather than trying to scrub records after the fact. A small denylist of sensitive keys (password, token, secret, authorization) applied recursively to the meta object keeps nested values from slipping through, and that helper should be the only path that writes to the log sink.