feat: sentry integration and nice error element (#119)

* init

Signed-off-by: Innei <i@innei.in>

* sentry init

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* feat: sentry config

Signed-off-by: Innei <i@innei.in>

* feat: nice error element

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-07-09 23:33:22 +08:00 committed by GitHub
parent ad26bd6f76
commit 2efb3a7086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1465 additions and 34 deletions

View File

@ -1,3 +1,5 @@
VITE_WEB_URL=http://localhost:5173
VITE_API_URL=http://localhost:3000
VITE_IMGPROXY_URL=http://localhost:2873
VITE_SENTRY_DSN=
VITE_BUILD_TYPE=production

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ out
.env
.eslintcache
.env.*
# Sentry Config File
.env.sentry-build-plugin

View File

@ -1,6 +1,7 @@
import fs from "node:fs"
import { resolve } from "node:path"
import { sentryVitePlugin } from "@sentry/vite-plugin"
import react from "@vitejs/plugin-react"
import { defineConfig } from "electron-vite"
@ -23,10 +24,30 @@ export default defineConfig({
"@pkg": resolve("./package.json"),
},
},
plugins: [react()],
plugins: [
react(),
sentryVitePlugin({
org: "follow-rg",
project: "follow",
bundleSizeOptimizations: {
excludeDebugStatements: true,
// Only relevant if you added `browserTracingIntegration`
excludePerformanceMonitoring: true,
// Only relevant if you added `replayIntegration`
excludeReplayIframe: true,
excludeReplayShadowDom: true,
excludeReplayWorker: true,
},
moduleMetadata: {
appVersion:
process.env.NODE_ENV === "development" ? "dev" : pkg.version,
},
}),
],
define: {
APP_VERSION: JSON.stringify(pkg.version),
APP_NAME: JSON.stringify(pkg.productName),
APP_DEV_CWD: JSON.stringify(process.cwd()),
},
},
})

View File

@ -8,6 +8,10 @@
"author": "example.com",
"license": "UNLICENSED",
"homepage": "https://electron-vite.org",
"repository": {
"url": "https://github.com/RSSNext/follow",
"type": "git"
},
"main": "./dist/main/index.js",
"scripts": {
"build": "npm run typecheck && electron-vite build --outDir=dist && electron-forge package && electron-forge make",
@ -48,6 +52,9 @@
"@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-toast": "1.2.1",
"@radix-ui/react-tooltip": "1.1.2",
"@sentry/electron": "5.1.0",
"@sentry/react": "8.9.2",
"@sentry/vite-plugin": "2.20.1",
"@shikijs/transformers": "1.10.0",
"@tanstack/query-sync-storage-persister": "5.49.0",
"@tanstack/react-query": "5.49.0",

File diff suppressed because it is too large Load Diff

9
src/main/helper.ts Normal file
View File

@ -0,0 +1,9 @@
import path from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const iconMap = {
prod: path.join(__dirname, "../../resources/icon.png"),
dev: path.join(__dirname, "../../resources/icon-dev.png"),
}
export const getIconPath = () => iconMap[process.env.NODE_ENV === "development" ? "dev" : "prod"]

View File

@ -1,35 +1,33 @@
import path from "node:path"
import { registerIpcMain } from "@egoist/tipc/main"
import * as Sentry from "@sentry/electron/main"
import { app } from "electron"
import { getIconPath } from "./helper"
import { registerAppMenu } from "./menu"
import { router } from "./tipc"
const iconMap = {
prod: path.join(__dirname, "../../resources/icon.png"),
dev: path.join(__dirname, "../../resources/icon-dev.png"),
}
const appFolder = {
prod: "Follow",
dev: "Follow (dev)",
}
const isDev = process.env.NODE_ENV === "development"
export const initializationApp = () => {
Sentry.init({
dsn: process.env.VITE_SENTRY_DSN,
})
registerIpcMain(router)
app.setPath(
"appData",
path.join(
app.getPath("appData"),
isDev ? appFolder.dev : appFolder.prod,
),
path.join(app.getPath("appData"), isDev ? appFolder.dev : appFolder.prod),
)
if (app.dock) {
app.dock.setIcon(
isDev ? iconMap.dev : iconMap.prod,
)
app.dock.setIcon(getIconPath())
}
// In this file you can include the rest of your app"s specific main process

View File

@ -1,11 +1,12 @@
import path from "node:path"
import { fileURLToPath } from "node:url"
import { is } from "@electron-toolkit/utils"
import { callGlobalContextMethod } from "@shared/bridge"
import type { BrowserWindowConstructorOptions } from "electron"
import { BrowserWindow, shell } from "electron"
import icon from "../../resources/icon.png?asset"
import { getIconPath } from "./helper"
import { store } from "./store"
const windows = {
@ -14,7 +15,7 @@ const windows = {
}
const { platform } = process
const __dirname = fileURLToPath(new URL(".", import.meta.url))
export function createWindow(
options: {
extraPath?: string
@ -30,7 +31,7 @@ export function createWindow(
show: false,
resizable: configs?.resizable ?? true,
autoHideMenuBar: true,
...(platform === "linux" ? { icon } : {}),
...(platform === "linux" ? { icon: getIconPath() } : {}),
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,

View File

@ -40,6 +40,7 @@ function App() {
document.removeEventListener("keydown", handleOpenSettings)
}
}, [])
const windowsElectron = window.electron && getOS() === "Windows"
return (
<>
@ -53,9 +54,7 @@ function App() {
<div className="drag-region fixed top-0 z-[99999] flex h-[24px] w-full items-center justify-end rounded-t-[12px] bg-background">
<div className="absolute left-5 top-0 flex h-[24px] items-center gap-2">
<Logo className="size-4" />
<span className="text-sm font-bold">
{APP_NAME}
</span>
<span className="text-sm font-bold">{APP_NAME}</span>
</div>
<button
className="no-drag-region flex h-[24px] w-[32px] items-center justify-center rounded duration-200 hover:bg-theme-item-active"

View File

@ -0,0 +1,33 @@
import type { FallbackRender } from "@sentry/react"
import { ErrorBoundary } from "@sentry/react"
import type { FC, PropsWithChildren } from "react"
import { useCallback } from "react"
export interface AppErrorBoundaryProps extends PropsWithChildren {
height?: number | string
}
export const AppErrorBoundary: FC<AppErrorBoundaryProps> = (props) => {
const fallbackRender: FallbackRender = useCallback(
(fallbackProps) => (
<ErrorFallback {...fallbackProps} height={props.height} />
),
[props.height],
)
const onError = useCallback((error: unknown, componentStack?: string) => {
console.error("Uncaught error:", error, componentStack)
}, [])
return (
<ErrorBoundary fallback={fallbackRender} onError={onError}>
{props.children}
</ErrorBoundary>
)
}
export type AppErrorFallbackProps = Parameters<FallbackRender>[0] & {
height?: number | string
}
const ErrorFallback = (_props: AppErrorFallbackProps) => <div>App has crashed</div>

View File

@ -0,0 +1,139 @@
import { repository } from "@pkg"
import { clearLocalPersistStoreData } from "@renderer/store/utils/clear"
import { useEffect } from "react"
import { isRouteErrorResponse, useRouteError } from "react-router-dom"
import { StyledButton } from "../ui/button"
export const ErrorElement = DefaultErrorComponent
function DefaultErrorComponent() {
const error = useRouteError()
const message = isRouteErrorResponse(error) ?
`${error.status} ${error.statusText}` :
error instanceof Error ?
error.message :
JSON.stringify(error)
const stack = error instanceof Error ? error.stack : null
useEffect(() => {
console.error(
"Error handled by React Router default ErrorBoundary:",
error,
)
import("@sentry/react").then(({ captureException }) => {
captureException(error)
})
}, [error])
return (
<div className="select-text p-8">
<div className="drag-region fixed inset-x-0 top-0 h-12" />
<h2 className="mt-12 text-2xl">Unexpected Application Error!</h2>
<h3 className="text-xl">{message}</h3>
{import.meta.env.DEV && stack ? (
<div className="mt-4 cursor-text overflow-auto whitespace-pre rounded-md bg-red-50 p-4 font-mono text-sm text-red-600">
{attachOpenInEditor(stack)}
</div>
) : null}
<p className="my-8">
The App has a temporary problem, click the button below to try reloading
the app or another solution?
</p>
<div className="center gap-4">
<StyledButton
variant="outline"
onClick={() => {
clearLocalPersistStoreData()
window.location.href = "/"
}}
>
Reset Local Database
</StyledButton>
<StyledButton onClick={() => (window.location.href = "/")}>
Reload
</StyledButton>
</div>
<p className="mt-8">
Still having this issue? Please give feedback in Github, thanks!
<a
className="ml-2 cursor-pointer text-theme-accent-400/80 duration-200 hover:text-theme-accent"
href={`${repository.url}/issues/new?title=${encodeURIComponent(
`Error: ${message}`,
)}&body=${encodeURIComponent(
`### Error\n\n${message}\n\n### Stack\n\n\`\`\`\n${stack}\n\`\`\``,
)}`}
target="_blank"
rel="noreferrer"
>
Submit Issue
</a>
</p>
</div>
)
}
const attachOpenInEditor = (stack: string) => {
const lines = stack.split("\n")
return lines.map((line) => {
// A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
// Find the `localhost` part and open the file in the editor
if (!line.includes("at ")) {
return line
}
const match = line.match(/(http:\/\/localhost:\d+\/[^:]+):(\d+):(\d+)/)
if (match) {
const [o] = match
// Find `@fs/`
// Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26`
const realFsPath = o.split("@fs")[1]
if (realFsPath) {
return (
// Delete `v=` hash, like `v=757920f2`
<div
className="cursor-pointer"
key={line}
onClick={openInEditor.bind(
null,
realFsPath.replace(/\?v=[a-f0-9]+/, ""),
)}
>
{line}
</div>
)
} else {
// at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
const srcFsPath = o.split("/src")[1]
if (srcFsPath) {
const fs = srcFsPath.replace(/\?t=[a-f0-9]+/, "")
return (
<div
className="cursor-pointer"
key={line}
onClick={openInEditor.bind(
null,
`${APP_DEV_CWD}/src/renderer/src${fs}`,
)}
>
{line}
</div>
)
}
}
}
return line
})
}
// http://localhost:5173/src/App.tsx?t=1720527056591:41:9
const openInEditor = (file: string) => {
fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)
}

View File

@ -0,0 +1,31 @@
import type { BrowserOptions } from "@sentry/react"
import { FetchError } from "ofetch"
export const SentryConfig: BrowserOptions = {
// Performance Monitoring
tracesSampleRate: 1, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", import.meta.env.VITE_API_URL],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1,
beforeSend(event, hint) {
const error = hint.originalException
if (
error instanceof Error &&
(/Network Error/i.test(error.message) ||
/Fetch Error/i.test(error.message) ||
/XHR Error/i.test(error.message) ||
/adsbygoogle/i.test(error.message) ||
/Failed to fetch/i.test(error.message))
) {
return null
}
if (error instanceof FetchError) {
return null
}
return event
},
}

View File

@ -13,6 +13,12 @@ declare global {
export type Id = string
export type FeedId = Id
export type EntryId = Id
export const SENTRY_RELEASE: { id: string }
export const APP_DEV_CWD: string
export interface Window {
SENTRY_RELEASE: typeof SENTRY_RELEASE
}
}
export {}

View File

@ -1,7 +1,15 @@
import { authConfigManager } from "@hono/auth-js/react"
import { version } from "@pkg"
import { browserDB } from "@renderer/database"
import { registerGlobalContext } from "@shared/bridge"
import { enableMapSet } from "immer"
import { useEffect } from "react"
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from "react-router-dom"
import { toast } from "sonner"
import { subscribeNetworkStatus } from "./atoms/network"
@ -10,6 +18,7 @@ import {
getGeneralSettings,
subscribeShouldUseIndexedDB,
} from "./atoms/settings/general"
import { SentryConfig } from "./configs"
import { APP_NAME } from "./lib/constants"
import { appLog } from "./lib/log"
import { hydrateDatabaseToStore, setHydrated } from "./store/utils/hydrate"
@ -24,6 +33,33 @@ const cleanup = subscribeShouldUseIndexedDB((value) => {
}
setHydrated(true)
})
const initSentry = async () => {
if (!window.SENTRY_RELEASE) return
const Sentry = await import("@sentry/react")
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_BUILD_TYPE ?? "development",
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
Sentry.moduleMetadataIntegration(),
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
...SentryConfig,
})
Sentry.setTags({
appVersion: version,
})
}
export const initializeApp = async () => {
appLog(
`${APP_NAME}: Next generation information browser`,
@ -32,6 +68,7 @@ export const initializeApp = async () => {
appLog(`Initialize ${APP_NAME}...`)
const now = Date.now()
initSentry()
subscribeNetworkStatus()
registerGlobalContext({

View File

@ -6,6 +6,7 @@ import ReactDOM from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { setAppIsReady } from "./atoms/app"
import { AppErrorBoundary } from "./components/common/AppErrorBoundary"
import { initializeApp } from "./init"
import { getOS } from "./lib/utils"
import { router } from "./router"
@ -23,7 +24,9 @@ if (window.electron && getOS() === "Windows") {
}
ReactDOM.createRoot($container).render(
<React.StrictMode>
<RouterProvider router={router} />
<AppErrorBoundary>
<RouterProvider router={router} />
</AppErrorBoundary>
<ClickToComponent />
</React.StrictMode>,
)

View File

@ -62,15 +62,19 @@ export function EntryColumn() {
const entries = useEntriesByView()
const { entriesIds, isFetchingNextPage } = entries
const { entryId: activeEntryId, view, feedId: routeFeedId, isPendingEntry, isCollection } = useRouteParms()
const {
entryId: activeEntryId,
view,
feedId: routeFeedId,
isPendingEntry,
isCollection,
} = useRouteParms()
const activeEntry = useEntry(activeEntryId)
useEffect(() => {
if (!activeEntryId) return
if (
isCollection || isPendingEntry
) return
if (isCollection || isPendingEntry) return
const feedId = activeEntry?.feeds.id
if (!feedId) return
@ -167,10 +171,7 @@ const ListHeader: FC<{
},
})
if (
typeof routerParams.feedId === "number" ||
routerParams.isAllFeeds
) {
if (typeof routerParams.feedId === "number" || routerParams.isAllFeeds) {
subscriptionActions.markReadByView(routerParams.view)
} else if (
routerParams.level === levels.folder &&

View File

@ -132,7 +132,6 @@ export function FeedColumn() {
className="flex items-center gap-1 text-xl font-bold"
onClick={(e) => {
e.stopPropagation()
navigateBackHome()
}}
>

View File

@ -1,20 +1,26 @@
import { wrapCreateBrowserRouter } from "@sentry/react"
import { createBrowserRouter, createHashRouter } from "react-router-dom"
import App from "./App"
import { NotFound } from "./components/ui/not-found"
import { ErrorElement } from "./components/common/ErrorElement"
import { NotFound } from "./components/common/NotFound"
import { buildGlobRoutes } from "./lib/route-builder"
const globTree = import.meta.glob("./pages/**/*.tsx")
const tree = buildGlobRoutes(globTree)
const routerCreator = window.electron ? createHashRouter : createBrowserRouter
let routerCreator = window.electron ? createHashRouter : createBrowserRouter
if (window.SENTRY_RELEASE) {
routerCreator = wrapCreateBrowserRouter(createHashRouter)
}
export const router = routerCreator([
{
path: "/",
element: <App />,
children: tree,
errorElement: <ErrorElement />,
},
{
path: "*",

2
types/vite.d.ts vendored
View File

@ -5,6 +5,8 @@ interface ImportMetaEnv {
VITE_WEB_URL: string
VITE_API_URL: string
VITE_IMGPROXY_URL: string
VITE_SENTRY_DSN: string
VITE_BUILD_TYPE: string
}
interface ImportMeta {

View File

@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { sentryVitePlugin } from "@sentry/vite-plugin"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
@ -11,6 +12,7 @@ export default defineConfig({
build: {
outDir: resolve(__dirname, "out/web"),
target: "ES2022",
sourcemap: true,
},
root: "./src/renderer",
resolve: {
@ -21,9 +23,22 @@ export default defineConfig({
},
},
base: "/",
plugins: [react()],
plugins: [
react(),
sentryVitePlugin({
org: "follow-rg",
project: "follow",
moduleMetadata: {
appVersion:
process.env.NODE_ENV === "development" ? "dev" : pkg.version,
electron: false,
},
}),
],
define: {
APP_VERSION: JSON.stringify(pkg.version),
APP_NAME: JSON.stringify(pkg.productName),
APP_DEV_CWD: JSON.stringify(process.cwd()),
},
})