DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our crash screen points at a log file that has never seen a poll, and the log you can read clears itself back

Notifio is an Electron app that watches rental listing search pages from your own machine and alerts you when something new is posted. It runs an Express server inside the Electron main process, and the window loads the UI from localhost.

It has two loggers. I knew about both. What I had never done is check which lines go where, so I counted the call sites.

Logger Call sites Destination
logFile() in main.ts 18 userData/logs.txt, plus stdout
log() in monitor.ts 38 an in-memory array, capped at 200, plus stdout
log() in server.ts 2 same array

The file on disk gets the 18. The 40 that describe what the app is actually for go into a buffer that dies with the process.

The file

const LOG_PATH = path.join(app.getPath('userData'), 'logs.txt');

function logFile(msg: string): void {
  const line = `[${new Date().toISOString()}] ${msg}\n`;
  process.stdout.write(line);
  try { fs.appendFileSync(LOG_PATH, line); } catch { /* ignore */ }
}

process.on('uncaughtException', (err: Error) => {
  logFile(`UNCAUGHT: ${err.stack || err.message}`);
});
process.on('unhandledRejection', (reason: unknown) => {
  logFile(`UNHANDLED REJECTION: ${String(reason)}`);
});
Enter fullscreen mode Exit fullscreen mode

Eighteen calls, and if you read what they say they are all one subject: app ready, server starting, __dirname is this, app.isPackaged is that, the browsers path resolved here, window and tray created, system suspending, system resumed, cookies exported for a hostname, a startup error.

That is a lifecycle log. It is genuinely the right content for a file, because those are the lines you need when the app will not start at all, which is the failure a user cannot debug for themselves. I have written about that startup path before: a packaged Electron app has no console, so when startServer() throws we build a window out of a data URL and put the stack in it. The last line of that window is this:

`<pre ...>Notifio failed to start.<br><br>${msg}<br><br>Check logs: ${LOG_PATH}</pre>`
Enter fullscreen mode Exit fullscreen mode

So the only place in the entire product that tells a user where the log file is, is a screen that only renders when the app failed before the monitor ever ran. Which is coherent. The problem is that it is the only such place, so that path is also the answer anyone would give to "where are the logs" in any other situation, and in every other situation the file does not contain the answer.

The buffer

The monitor's 38 lines take a different route entirely:

function log(msg: string): void {
  const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
  console.log(line);
  _logListeners.forEach((fn) => fn(line));
}
Enter fullscreen mode Exit fullscreen mode

No file. It pushes to listeners, and the server subscribes:

monitor.onLog((line) => {
  if (line.includes('__AUTH_FAILURE__:')) { /* becomes an event, not a log line */ }
  logBuffer.push(line);
  if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
  broadcast({ type: 'log', line });
});
Enter fullscreen mode Exit fullscreen mode

LOG_BUFFER_SIZE is 200. The lines go out over SSE to the window and into a 200-slot ring, and grep -rn "logs.txt" src/ returns two hits, both in main.ts, both the declaration. Nothing else in the app knows that file exists.

One thing the two loggers disagree about turns out to be correct in both directions. The file writes toISOString() and the buffer writes toLocaleTimeString(), which is a date and a bare clock time respectively. For a file spanning weeks you need the date; for a session log in a window you do not, and the time reads better in the user's own locale. Nobody coordinated that and both are right.

Clear does not clear

Here is the part I found by reading rather than by using the app, which is embarrassing because it is trivially reproducible.

The activity log panel has a Clear button:

<ActivityLog lines={app.logLines} onClear={app.clearLog} />
Enter fullscreen mode Exit fullscreen mode

And clearLog is a renderer action:

const clearLog = useCallback(() => dispatch({ type: "logs_clear" }), []);
Enter fullscreen mode Exit fullscreen mode

It empties the React state. It does not touch logBuffer, which lives in the main process and is the actual owner of those lines. Now add the window lifecycle:

// Reload the UI when the window is re-shown after being hidden to prevent
// black screen and stale state (e.g. searches disappearing)
mainWindow.on('show', () => {
  mainWindow?.webContents.reload();
});
Enter fullscreen mode Exit fullscreen mode

And the SSE handshake:

app.get('/api/events', (req, res) => {
  // Replay enough for a client that has just connected (or reconnected after
  // the window was hidden) to render the full picture without extra requests.
  logBuffer.forEach((line) => res.write(`data: ${JSON.stringify({ type: 'log', line })}\n\n`));
Enter fullscreen mode Exit fullscreen mode

So: click Clear, close the window to the tray, click the tray icon. The window reloads, EventSource reconnects, the server replays all 200 lines, and your cleared log is back in full. Every individual decision there is right. The reload fixes a real black-screen bug. The replay is why the window can be thrown away and rebuilt without the app losing track of anything. Clearing local state is the obvious implementation of a Clear button.

They are only wrong together, which is the recurring shape of bugs in an app where one process owns the truth and another renders it. The renderer is allowed to read that state and not to mutate it, and the one button that pretends otherwise is the one that misbehaves.

The fix is a route, not a redesign:

app.delete('/api/logs', (_req, res) => {
  logBuffer.length = 0;
  broadcast({ type: 'logs_cleared' });
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

Clear becomes a request, the server empties the buffer it owns, and every connected window is told. Which is how the other 32 routes on this local server already work.

The real gap

Neither logger is wrong about what it collects. The gap is that the two sets never meet, so there is no artefact a user can send me that contains both why the app would not start and what the monitor did about a site last Tuesday. The buffer is capped at 200 lines and evaporates on quit, so by the time someone writes in, the evidence is gone.

What I am doing about it, in order of how much I trust it:

  1. Subscribe the file logger to monitor.onLog. Four lines, in startServer(), right next to where the login bridge is registered. The 38 lines start landing in the same file as the 18.
  2. Then, and only then, add rotation. Today logs.txt grows by roughly a dozen lines per run plus one per sleep and wake, which is slow enough to ignore for the app's whole life. Adding 38 call sites, several of them per poll, changes that completely: a poll cycle every 30 seconds, running for a week, is not a file you append to forever. Rotation at a size cap, keeping one previous file, before the firehose is connected rather than after.
  3. Say where it is. The log path appears in one data-URL error screen. It should be on the help page next to the contact form, because "attach this file" is the first thing I will ask anyway.

Takeaways

  • Count the call sites of each logger separately. "We log to a file" and "the interesting lines are in the file" are different claims, and mine disagreed by 38 to 18.
  • The log path belongs somewhere a user can find when the app is working, not only on the screen that renders when it is not.
  • In a two-process app, a button that mutates renderer state for data the other process owns will come back. Route the write to the owner and broadcast the result.
  • Wire the volume up to a log file only after you have a rotation policy. The order matters: the policy is cheap to add now and urgent later.

The app is on the download page, and the per-site pages show which sites it watches.

Top comments (0)