feat(errors): add Sentry observability behind a sinks facade

logError / reportError now route through a pair of sinks installed at
bootstrap by each process (main + every renderer entry). No call site
knows about Sentry — if sentryDsn is empty, the sinks simply aren't
installed and logError/reportError stay console-only.

- appConfig.sentryDsn: per-env string (empty for now — populate when
  ops creates the DSNs). Empty is the no-op mode for dev.
- lib/errors.ts: installErrorSinks({ capture, breadcrumb }) gates
  Sentry.captureException / Sentry.addBreadcrumb. Everything flows
  through toUserMessage and the two existing call types.
- lib/sentry.ts: initSentryRenderer() for the main window + autoplay,
  huddle, and screen-record renderers.
- main/sentry.ts: initSentryMain() runs before anything else in
  main.ts to catch bootstrap failures. Captures uncaught exceptions
  and the crash reporter automatically.
- main/ipc-utils.ts::safeHandle now routes through reportError.
- main.ts::fetchLinkMetadata logs via logError.
This commit is contained in:
Claude
2026-04-17 03:01:02 +00:00
parent 795bdfc286
commit b0281ae8f9
12 changed files with 677 additions and 7 deletions
+3
View File
@@ -1,6 +1,9 @@
import { createRoot } from 'react-dom/client';
import { AutoplayApp } from './AutoplayApp';
import { initSentryRenderer } from '@/lib/sentry';
import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
root.render(<AutoplayApp />);
+4
View File
@@ -23,6 +23,8 @@ type AppConfig = {
orionUrl: string;
pusherUrl: string;
firebase: FirebaseConfig;
/** Empty string disables Sentry. Populate per-env when ready to ship. */
sentryDsn: string;
};
const configs: Record<"dev" | "prod", AppConfig> = {
@@ -37,6 +39,7 @@ const configs: Record<"dev" | "prod", AppConfig> = {
projectId: "flowy-dev-440017",
storageBucket: "flowy-dev-440017.firebasestorage.app",
},
sentryDsn: "",
},
prod: {
orionUrl: "https://orion.flowy.live",
@@ -49,6 +52,7 @@ const configs: Record<"dev" | "prod", AppConfig> = {
projectId: "flowy-prod-440017",
storageBucket: "flowy-prod-440017.firebasestorage.app",
},
sentryDsn: "",
},
};
+3
View File
@@ -1,6 +1,9 @@
import { createRoot } from 'react-dom/client';
import { HuddleApp } from './HuddleApp';
import { initSentryRenderer } from '@/lib/sentry';
import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
root.render(<HuddleApp />);
+19 -2
View File
@@ -59,17 +59,34 @@ export function toUserMessage(err: unknown): string {
}
type ErrorContext = Record<string, unknown>;
type ErrorSink = (err: unknown, context?: ErrorContext) => void;
/** Expected-but-recordable failures. Dev logs; prod is currently a no-op. */
// Sentry (or any observability backend) installs itself via `installErrorSinks`
// from renderer.tsx / main.ts. Until then, logError is a dev-only console call
// and reportError always prints — no call site needs to know.
let captureSink: ErrorSink | null = null;
let breadcrumbSink: ErrorSink | null = null;
export function installErrorSinks(sinks: {
capture: ErrorSink;
breadcrumb: ErrorSink;
}): void {
captureSink = sinks.capture;
breadcrumbSink = sinks.breadcrumb;
}
/** Expected-but-recordable failures. Breadcrumb only — never pages anyone. */
export function logError(err: unknown, context?: ErrorContext): void {
if (appEnv === "dev") {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
}
breadcrumbSink?.(err, context);
}
/** Unexpected failures the user may not see. Always surfaces somewhere. */
/** Unexpected failures the user may not see. Always captured. */
export function reportError(err: unknown, context?: ErrorContext): void {
// eslint-disable-next-line no-console
console.error("[error]", err, context ?? {});
captureSink?.(err, context);
}
+28
View File
@@ -0,0 +1,28 @@
import * as Sentry from "@sentry/electron/renderer";
import { appConfig } from "@/config/env";
import { installErrorSinks } from "@/lib/errors";
/**
* Initialise Sentry for a renderer process. No-ops when `sentryDsn` is empty
* so dev builds and unconfigured envs stay quiet.
*/
export function initSentryRenderer(): void {
if (!appConfig.sentryDsn) return;
Sentry.init({
dsn: appConfig.sentryDsn,
tracesSampleRate: 0,
});
installErrorSinks({
capture: (err, context) =>
Sentry.captureException(err, { extra: context }),
breadcrumb: (err, context) =>
Sentry.addBreadcrumb({
category: "error",
level: "error",
message: err instanceof Error ? err.message : String(err),
data: context,
}),
});
}
+5 -2
View File
@@ -5,7 +5,11 @@ import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
import type { LinkMetadata } from './lib/link-metadata';
import { appConfig } from './config/env';
import { logError } from './lib/errors';
import { safeHandle } from './main/ipc-utils';
import { initSentryMain } from './main/sentry';
initSentryMain();
if (app.isPackaged) {
updateElectronApp({
@@ -461,8 +465,7 @@ async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
} catch (err) {
// Metadata is a progressive enhancement — keep the null contract, but log
// so upstream failures (DNS, TLS, aborted fetches) aren't invisible.
// eslint-disable-next-line no-console
console.error('[ipc:link.fetch-metadata]', err);
logError(err, { scope: 'link.fetchMetadata', url });
return null;
}
}
+2 -2
View File
@@ -1,4 +1,5 @@
import { ipcMain, type IpcMainInvokeEvent } from "electron";
import { reportError } from "../lib/errors";
type InvokeHandler = (
event: IpcMainInvokeEvent,
@@ -14,8 +15,7 @@ export function safeHandle(channel: string, handler: InvokeHandler): void {
try {
return await handler(event, ...(args as unknown[]));
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[ipc:${channel}]`, err);
reportError(err, { scope: `ipc.${channel}` });
const message = err instanceof Error ? err.message : "IPC error";
throw new Error(message);
}
+30
View File
@@ -0,0 +1,30 @@
import { app } from "electron";
import * as Sentry from "@sentry/electron/main";
import { appConfig } from "../config/env";
import { installErrorSinks } from "../lib/errors";
/**
* Initialise Sentry for the main process. Captures uncaught exceptions from
* the Node side and the crash reporter. Safe to call before `app.whenReady`.
*/
export function initSentryMain(): void {
if (!appConfig.sentryDsn) return;
Sentry.init({
dsn: appConfig.sentryDsn,
tracesSampleRate: 0,
release: app.getVersion(),
});
installErrorSinks({
capture: (err, context) =>
Sentry.captureException(err, { extra: context }),
breadcrumb: (err, context) =>
Sentry.addBreadcrumb({
category: "error",
level: "error",
message: err instanceof Error ? err.message : String(err),
data: context,
}),
});
}
+3
View File
@@ -1,6 +1,9 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import { initSentryRenderer } from '@/lib/sentry';
import './styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
+3
View File
@@ -1,6 +1,9 @@
import { createRoot } from 'react-dom/client';
import { ScreenRecordControlApp } from './ScreenRecordControlApp';
import { initSentryRenderer } from '@/lib/sentry';
import '@/styles/globals.css';
initSentryRenderer();
const root = createRoot(document.getElementById('root')!);
root.render(<ScreenRecordControlApp />);