feat: migrate desktop/mobile error tracking to posthog (#4858)

* feat: migrate desktop and mobile error tracking to PostHog

* fix(mobile): remove unsupported tab bar minimize API

* Revert "fix(mobile): remove unsupported tab bar minimize API"

This reverts commit e234dda51257dc6810f0f78e06ee918adf2214b0.
This commit is contained in:
DIYgod 2026-02-18 23:16:00 +08:00 committed by GitHub
parent 47a56c5017
commit 0da9f2098e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 171 additions and 1399 deletions

View File

@ -1,7 +1,6 @@
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { sentryVitePlugin } from "@sentry/vite-plugin"
import react from "@vitejs/plugin-react"
import { codeInspectorPlugin } from "code-inspector-plugin"
import { dirname, resolve } from "pathe"
@ -17,9 +16,6 @@ import i18nCompleteness from "../plugins/vite/utils/i18n-completeness"
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const pkg = JSON.parse(readFileSync(resolve(pkgDir, "./package.json"), "utf8"))
const isCI = process.env.CI === "true" || process.env.CI === "1"
const mode = process.argv.find((arg) => arg.startsWith("--mode"))?.split("=")[1]
const isStaging = mode === "staging"
const getChangelogFileContent = () => {
const { version: pkgVersion } = pkg
@ -76,36 +72,6 @@ export const viteRenderBaseConfig = {
}),
circularImportRefreshPlugin(),
sentryVitePlugin({
org: "follow-rg",
project: "follow",
disable: !isCI,
bundleSizeOptimizations: {
excludeDebugStatements: true,
// Only relevant if you added `replayIntegration`
excludeReplayIframe: true,
excludeReplayShadowDom: true,
excludeReplayWorker: true,
},
moduleMetadata: {
appVersion: process.env.NODE_ENV === "development" ? "dev" : pkg.version,
electron: false,
},
sourcemaps: {
filesToDeleteAfterUpload: isStaging
? []
: [
"out/web/assets/*.js.map",
"out/web/vendor/*.js.map",
"out/rn-web/assets/*.js.map",
"out/rn-web/vendor/*.js.map",
"dist/renderer/assets/*.js.map",
"dist/renderer/vendor/*.css.map",
],
},
}),
astPlugin,
customI18nHmrPlugin(),
],

View File

@ -28,7 +28,6 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@openpanel/web": "1.0.7",
"@sentry/electron": "7.2.0",
"builder-util-runtime": "9.5.1",
"electron-context-menu": "4.1.1",
"electron-ipc-decorator": "0.2.0",

View File

@ -1,14 +1,8 @@
import { app, protocol } from "electron"
import path from "pathe"
import { initializeSentry } from "./sentry"
if (import.meta.env.DEV) app.setPath("userData", path.join(app.getPath("appData"), "Folo(dev)"))
protocol.registerSchemesAsPrivileged([
{
scheme: "sentry-ipc",
privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
},
{
scheme: "app",
privileges: {
@ -19,5 +13,3 @@ protocol.registerSchemesAsPrivileged([
},
},
])
// Solve Sentry SDK should be initialized before the Electron app 'ready' event is fired
initializeSentry()

View File

@ -1,41 +0,0 @@
import { captureConsoleIntegration, init, setTag } from "@sentry/electron/main"
import { app } from "electron"
import { FetchError } from "ofetch"
import { DEVICE_ID } from "./constants/system"
export const initializeSentry = () => {
init({
dsn: process.env.VITE_SENTRY_DSN,
integrations: [
captureConsoleIntegration({
levels: ["error"],
}),
],
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) ||
error.message.includes("fetch failed"))
) {
return null
}
if (error instanceof FetchError) {
return null
}
return event
},
})
setTag("device_id", DEVICE_ID)
setTag("app_version", app.getVersion())
setTag("build", "electron")
}

View File

@ -38,7 +38,6 @@
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@sentry/react": "10.38.0",
"@shikijs/transformers": "3.22.0",
"@splinetool/react-spline": "4.1.0",
"@tanstack/query-sync-storage-persister": "5.90.22",
@ -88,6 +87,7 @@
"react": "19.0.0",
"react-blurhash": "0.3.0",
"react-dom": "19.0.0",
"react-error-boundary": "6.1.0",
"react-fast-compare": "3.2.2",
"react-fast-marquee": "1.6.5",
"react-google-recaptcha-v3": "1.11.0",

View File

@ -1,11 +1,11 @@
import type { FallbackRender } from "@sentry/react"
import { ErrorBoundary } from "@sentry/react"
import type { FC, PropsWithChildren } from "react"
import { createElement, Suspense, useCallback } from "react"
import { getErrorFallback } from "../errors"
import type { ErrorComponentType } from "../errors/enum"
import PageErrorFallback from "../errors/PageError"
import type { FallbackRender } from "./ErrorBoundary"
import { ErrorBoundary } from "./ErrorBoundary"
export interface AppErrorBoundaryProps extends PropsWithChildren {
height?: number | string

View File

@ -0,0 +1,55 @@
import { tracker } from "@follow/tracker"
import type { PropsWithChildren, ReactNode } from "react"
import type { FallbackProps } from "react-error-boundary"
import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"
export type ErrorFallbackProps = Omit<FallbackProps, "resetErrorBoundary"> &
FallbackProps & {
resetError: () => void
}
export type FallbackRender = (props: ErrorFallbackProps) => ReactNode
interface ErrorBoundaryProps extends PropsWithChildren {
fallback?: FallbackRender
fallbackRender?: FallbackRender
handled?: boolean
beforeCapture?: (scope: unknown, error: unknown) => unknown
}
const emptyFallback: FallbackRender = () => null
export const ErrorBoundary = ({
children,
fallback,
fallbackRender,
beforeCapture,
}: ErrorBoundaryProps) => {
const renderFallback = fallbackRender ?? fallback ?? emptyFallback
const handleError = (rawError: unknown, info: { componentStack?: string | null }) => {
const error = rawError instanceof Error ? rawError : new Error(String(rawError))
if (beforeCapture?.(info, error) === false) {
return
}
void tracker.manager.captureException(error, {
source: "desktop_error_boundary",
component_stack: info.componentStack,
})
}
return (
<ReactErrorBoundary
onError={handleError}
fallbackRender={(props) =>
renderFallback({
...props,
resetError: props.resetErrorBoundary,
})
}
>
{children}
</ReactErrorBoundary>
)
}

View File

@ -1,5 +1,5 @@
import { Button } from "@follow/components/ui/button/index.js"
import { captureException } from "@sentry/react"
import { tracker } from "@follow/tracker"
import { useEffect, useRef } from "react"
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router"
import { toast } from "sonner"
@ -27,8 +27,9 @@ export function ErrorElement() {
useEffect(() => {
console.error("Error handled by React Router default ErrorBoundary:", error)
captureException(error)
void tracker.manager.captureException(error, {
source: "desktop_router_error_element",
})
}, [error])
const reloadRef = useRef(false)

View File

@ -1,7 +1,6 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { ELECTRON_BUILD } from "@follow/shared/constants"
import { captureException } from "@sentry/react"
import { useEffect } from "react"
import type { Location } from "react-router"
import { Navigate, useLocation, useNavigate } from "react-router"
@ -33,7 +32,7 @@ export const NotFound = () => {
if (!ELECTRON_BUILD) {
return
}
captureException(
console.error(
new AccessNotFoundError(
"Electron app got to a 404 page, this should not happen",
location.pathname,

View File

@ -1,10 +1,14 @@
import { captureException } from "@sentry/react"
import { tracker } from "@follow/tracker"
import { useEffect } from "react"
export const BlockError = (props: { error: any; message: string }) => {
useEffect(() => {
captureException(props.error)
}, [])
console.error(props.error)
void tracker.manager.captureException(props.error, {
source: "desktop_markdown_block_error",
message: props.message,
})
}, [props.error, props.message])
return (
<div className="center flex min-h-12 flex-col rounded bg-red py-4 text-sm text-white">
{props.message}

View File

@ -1,6 +1,5 @@
import { nextFrame } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import { useForceUpdate } from "motion/react"
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
import * as React from "react"
@ -11,6 +10,7 @@ import { useEventCallback } from "usehooks-ts"
import { useGetImageProxyUrl } from "~/lib/img-proxy"
import { saveImageDimensionsToDb } from "~/store/image/db"
import { ErrorBoundary } from "../../common/ErrorBoundary"
import { useMediaContainerWidth, usePreviewMedia } from "./hooks"
import { MediaInfoRecordContext } from "./MediaInfoRecordContext"
import type { VideoPlayerRef } from "./VideoPlayer"

View File

@ -30,6 +30,11 @@ export const initAnalytics = async () => {
api_host: env.VITE_POSTHOG_HOST,
person_profiles: "identified_only",
defaults: "2025-05-24",
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: false,
},
}),
)

View File

@ -1,10 +1,6 @@
import { tracker } from "@follow/tracker"
import type { AuthUser } from "@follow-app/client-sdk"
export const setIntegrationIdentify = async (user: AuthUser) => {
export const setIntegrationIdentify = (user: AuthUser) => {
tracker.identify(user)
await import("@sentry/react").then(({ setTag }) => {
setTag("user_id", user.id)
setTag("user_name", user.name)
})
}

View File

@ -16,7 +16,6 @@ import { appLog } from "../lib/log"
import { initAnalytics } from "./analytics"
import { registerHistoryStack } from "./history"
import { doMigration } from "./migrates"
import { initSentry } from "./sentry"
import { initializeSettings } from "./settings"
declare global {
@ -82,7 +81,6 @@ export const initializeApp = async () => {
apm("initializeSettings", initializeSettings)
initSentry()
await apm("i18n", initI18n)
apm("setting sync", () => {

View File

@ -1,83 +0,0 @@
import { env } from "@follow/shared/env.desktop"
import type { BrowserOptions } from "@sentry/react"
import {
captureConsoleIntegration,
eventFiltersIntegration,
httpClientIntegration,
moduleMetadataIntegration,
reactRouterV6BrowserTracingIntegration,
} from "@sentry/react"
import { useEffect } from "react"
import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from "react-router"
const ERROR_PATTERNS = [
/Network Error/i,
/Fetch Error/i,
/XHR Error/i,
/adsbygoogle/i,
/Failed to fetch/i,
"FetchError",
"FollowAuthError",
"fetch failed",
"Unable to open cursor",
"Document is not focused.",
"Tracker",
"HTTP Client Error",
// Biz errors
"Chain aborted",
"The database connection is closing",
"NotSupportedError",
"Request failed",
"The user rejected the request",
"TypeError: Failed to fetch",
"ResizeObserver loop completed with undelivered notifications",
"ResizeObserver loop limit exceeded",
"A mutation operation was attempted on a database that did not allow mutations",
"401",
"HTTP Client Error with status code: ",
"DatabaseClosedError",
"SecurityError",
"NotFoundError",
"Large Render Blocking Asset",
]
export const SentryConfig: BrowserOptions = {
dsn: env.VITE_SENTRY_DSN,
environment: RELEASE_CHANNEL,
integrations: [
eventFiltersIntegration(),
moduleMetadataIntegration(),
httpClientIntegration(),
reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
captureConsoleIntegration({
levels: ["error"],
}),
],
ignoreErrors: ERROR_PATTERNS,
// Performance Monitoring
tracesSampleRate: 1, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", 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 && "traceId" in error && error.traceId) {
event.tags = {
...event.tags,
traceId: error.traceId as string,
}
}
return event
},
}

View File

@ -1,32 +0,0 @@
import { ELECTRON_BUILD } from "@follow/shared/constants"
import { whoami } from "@follow/store/user/getters"
import { appSessionTraceId } from "@follow/utils/environment"
import { version } from "@pkg"
import { nanoid } from "nanoid"
import { SentryConfig } from "./sentry.config"
Object.defineProperty(window.Error.prototype, "traceId", {
get() {
if (!this._traceId) {
this._traceId = nanoid()
}
return this._traceId
},
})
export const initSentry = async () => {
if (!window.SENTRY_RELEASE) return
if (import.meta.env.DEV) return
const Sentry = await import("@sentry/react")
Sentry.init(SentryConfig)
const user = whoami()
if (user) {
Sentry.setTag("user_id", user.id)
Sentry.setTag("user_name", user.name)
}
Sentry.setTag("session_trace_id", appSessionTraceId)
Sentry.setTag("app_version", version)
Sentry.setTag("build", ELECTRON_BUILD ? "electron" : "web")
}

View File

@ -32,7 +32,7 @@ export const getNewIssueUrl = ({
if (title) searchParams.set("title", title)
if (error && "traceId" in error && error.traceId) {
nextBody += `\n\n### Sentry Trace ID\n${error.traceId}`
nextBody += `\n\n### Trace ID\n${error.traceId}`
}
searchParams.set("body", nextBody)

View File

@ -1,7 +1,8 @@
import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { createElement, lazy, Suspense } from "react"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
const AISplineLoader = lazy(() =>
import("./AISplineLoader").then((res) => ({ default: res.AISplineLoader })),
)

View File

@ -1,6 +1,6 @@
import { Button } from "@follow/components/ui/button/index.js"
import type { FallbackRender } from "@sentry/react"
import type { FallbackRender } from "~/components/common/ErrorBoundary"
import { attachOpenInEditor } from "~/lib/dev"
import { FeedbackIssue } from "../../../../components/common/ErrorElement"

View File

@ -11,7 +11,6 @@ import { useUserRole } from "@follow/store/user/hooks"
import { tracker } from "@follow/tracker"
import { detectIsEditableElement, nextFrame } from "@follow/utils"
import type { ConfigResponse } from "@follow-app/client-sdk"
import { ErrorBoundary } from "@sentry/react"
import type { EditorState } from "lexical"
import { createEditor } from "lexical"
import { nanoid } from "nanoid"
@ -21,6 +20,7 @@ import { useEventCallback, useEventListener } from "usehooks-ts"
import { useAISettingKey } from "~/atoms/settings/ai"
import { useActionLanguage } from "~/atoms/settings/general"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useRequireLogin } from "~/hooks/common/useRequireLogin"

View File

@ -1,10 +1,10 @@
import "@xyflow/react/dist/style.css"
import { alwaysFalse } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import type { ReasoningUIPart, TextUIPart, ToolUIPart } from "ai"
import * as React from "react"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
import type { AIDisplayFlowTool, BizUIMessage, BizUITools } from "~/modules/ai-chat/store/types"
import { useChatStatus } from "../../store/hooks"

View File

@ -6,12 +6,12 @@ import { useIsInbox } from "@follow/store/inbox/hooks"
import { thenable } from "@follow/utils"
import { stopPropagation } from "@follow/utils/dom"
import { clsx } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { memo } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"

View File

@ -1,7 +1,7 @@
import { Button } from "@follow/components/ui/button/index.js"
import type { FallbackRender } from "@sentry/react"
import { useTranslation } from "react-i18next"
import type { FallbackRender } from "~/components/common/ErrorBoundary"
import { getNewIssueUrl } from "~/lib/issues"
export const EntryRenderError: FallbackRender = ({ error }) => {

View File

@ -5,7 +5,6 @@ import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
@ -15,6 +14,7 @@ import {
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ErrorBoundary } from "~/components/common/ErrorBoundary"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"

View File

@ -1,5 +1,4 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { wrapCreateBrowserRouterV7 } from "@sentry/react"
import { createBrowserRouter, createHashRouter } from "react-router"
import { Component as App } from "./App"
@ -8,11 +7,8 @@ import { NotFound } from "./components/common/NotFound"
// @ts-ignore
import { routes as tree } from "./generated-routes"
let routerCreator =
const routerCreator =
IN_ELECTRON || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
if (window.SENTRY_RELEASE) {
routerCreator = wrapCreateBrowserRouterV7(routerCreator)
}
export const router = routerCreator([
{

View File

@ -1,5 +1,4 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { wrapCreateBrowserRouterV7 } from "@sentry/react"
import { createBrowserRouter, createHashRouter } from "react-router"
import { ErrorElement } from "./components/common/ErrorElement"
@ -7,11 +6,8 @@ import { NotFound } from "./components/common/NotFound"
// @ts-ignore
import { routes as tree } from "./generated-routes"
let routerCreator =
const routerCreator =
IN_ELECTRON || globalThis["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
if (window.SENTRY_RELEASE) {
routerCreator = wrapCreateBrowserRouterV7(routerCreator)
}
export const router = routerCreator([
{

View File

@ -52,7 +52,6 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@pengx17/electron-forge-maker-appimage": "1.2.1",
"@sentry/vite-plugin": "4.9.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@vitejs/plugin-legacy": "7.2.1",

View File

@ -285,7 +285,7 @@ export default ({ mode }) => {
],
["tldts"],
["@sentry/react", "@openpanel/web"],
["@openpanel/web"],
["zod", "react-hook-form", "@hookform/resolvers"],
]),

View File

@ -137,14 +137,6 @@ export default ({ config }: ConfigContext): ExpoConfig => {
require("./plugins/with-android-manifest-plugin.js"),
"expo-secure-store",
"@react-native-firebase/app",
[
"@sentry/react-native/expo",
{
url: "https://sentry.io/",
project: "react-native",
organization: "follow-rg",
},
],
[
"expo-image-picker",
{

View File

@ -170,7 +170,6 @@
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
6E4ADCBDA5B760296BAE78FA /* [CP] Embed Pods Frameworks */,
8F2D99FDEA6998C9D98BD7E0 /* [CP-User] [RNFB] Core Configuration */,
3B0B406257C84B7586048134 /* Upload Debug Symbols to Sentry */,
);
buildRules = (
);
@ -243,7 +242,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@ -267,20 +266,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B0B406257C84B7586048134 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh ../../../node_modules/@sentry/react-native/scripts/sentry-xcode-debug-files.sh\n";
};
6E4ADCBDA5B760296BAE78FA /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@ -335,7 +320,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
@ -374,7 +358,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SnapKit_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",

View File

@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=follow-rg
defaults.project=react-native
# Using SENTRY_AUTH_TOKEN environment variable

View File

@ -1,9 +1,9 @@
const { getDefaultConfig } = require("expo/metro-config")
const { withNativeWind } = require("nativewind/metro")
const path = require("pathe")
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config")
const { getSentryExpoConfig } = require("@sentry/react-native/metro")
const config = getSentryExpoConfig(__dirname, { isCSSEnabled: true })
const config = getDefaultConfig(__dirname, { isCSSEnabled: true })
const workspaceRoot = path.resolve(__dirname, "../..")
config.resolver.sourceExts.push("sql")

View File

@ -43,7 +43,6 @@
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native-menu/menu": "2.0.0",
"@react-native-picker/picker": "2.11.1",
"@sentry/react-native": "7.2.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/query-sync-storage-persister": "5.90.22",
"@tanstack/react-query": "5.90.21",

View File

@ -1,5 +1,5 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { captureException } from "@sentry/react-native"
import { tracker } from "@follow/tracker"
import type { FC } from "react"
import { createElement, useEffect } from "react"
import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"
@ -52,8 +52,10 @@ const defaultFallbackRender = ({ error }: { error: Error }) => {
}
const ErrorReport = ({ error }: { error: Error }) => {
useEffect(() => {
captureException(error)
console.error(error)
void tracker.manager.captureException(error, {
source: "mobile_error_boundary",
})
}, [error])
return null
}

View File

@ -10,9 +10,19 @@ import { proxyEnv } from "../lib/proxy-env"
export const initAnalytics = async () => {
setFirebaseTracker(getAnalytics())
const user = whoami()
if (user) {
tracker.identify(user as AuthUser)
if (proxyEnv.POSTHOG_KEY) {
setPostHogTracker(
new PostHog(proxyEnv.POSTHOG_KEY, {
host: proxyEnv.POSTHOG_HOST,
errorTracking: {
autocapture: {
uncaughtExceptions: true,
unhandledRejections: true,
console: false,
},
},
}),
)
}
tracker.manager.appendUserProperties({
@ -21,8 +31,9 @@ export const initAnalytics = async () => {
buildId: nativeBuildVersion,
})
if (proxyEnv.POSTHOG_KEY) {
setPostHogTracker(new PostHog(proxyEnv.POSTHOG_KEY))
const user = whoami()
if (user) {
tracker.identify(user as AuthUser)
}
// op.setGlobalProperties({

View File

@ -13,7 +13,6 @@ import { initDeviceType } from "./device"
import { hydrateQueryClient, hydrateSettings } from "./hydrate"
import { migrateDatabase } from "./migration"
import { initializePlayer } from "./player"
import { initializeSentry } from "./sentry"
/* eslint-disable no-console */
export const initializeApp = async () => {
@ -21,8 +20,6 @@ export const initializeApp = async () => {
const now = Date.now()
initializeSentry()
await initDeviceType()
await initializeDB()
void apm("migrateLegacyApiSession", migrateLegacyApiSession).catch((error) => {

View File

@ -1,11 +0,0 @@
import * as Sentry from "@sentry/react-native"
export const initializeSentry = () => {
// Unlike Sentry on other platforms, you do not need to import anything to use tracing on React Native
Sentry.init({
dsn: "https://cbfecd786e09a9481676655a4da88a7e@o4507542488023040.ingest.us.sentry.io/4509926421102593",
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: 1,
})
}

View File

@ -2,7 +2,6 @@ import "./global.css"
import "./polyfill"
import { apiContext, authClientContext, queryClientContext } from "@follow/store/context"
import * as Sentry from "@sentry/react-native"
import { registerRootComponent } from "expo"
import { Image } from "expo-image"
import { LinearGradient } from "expo-linear-gradient"
@ -45,7 +44,7 @@ enableFreeze(true)
initializeApp()
registerSitemap()
initializeI18n()
registerRootComponent(Sentry.wrap(RootComponent))
registerRootComponent(RootComponent)
function RootComponent() {
const { t } = useTranslation()

View File

@ -11,6 +11,11 @@ export type TrackPayload = {
properties?: Record<string, unknown>
}
export type CaptureExceptionPayload = {
error: unknown
properties?: Record<string, unknown>
}
export interface TrackerAdapter {
/**
* Initialize the tracker adapter
@ -22,6 +27,11 @@ export interface TrackerAdapter {
*/
track: (payload: TrackPayload) => Promise<void> | void
/**
* Capture an exception
*/
captureException?: (payload: CaptureExceptionPayload) => Promise<void> | void
/**
* Identify a user
*/

View File

@ -1,4 +1,4 @@
export type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export { FirebaseAdapter, type FirebaseAdapterConfig } from "./firebase"
export { OpenPanelAdapter, type OpenPanelAdapterConfig } from "./openpanel"
export { PostHogAdapter, type PostHogAdapterConfig } from "./posthog"

View File

@ -1,7 +1,7 @@
import type { PostHog } from "posthog-js"
import type PostHogReactNative from "posthog-react-native"
import type { IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
import type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base"
export interface PostHogAdapterConfig {
instance: PostHog | PostHogReactNative
@ -31,6 +31,19 @@ export class PostHogAdapter implements TrackerAdapter {
}
}
async captureException({ error, properties }: CaptureExceptionPayload): Promise<void> {
if (!this.isEnabled()) return
try {
this.posthogInstance.captureException(
error,
properties as Parameters<typeof this.posthogInstance.captureException>[1],
)
} catch (captureError) {
console.error("[PostHog] Failed to capture exception:", captureError)
}
}
async identify(payload: IdentifyPayload): Promise<void> {
if (!this.isEnabled()) return

View File

@ -10,6 +10,7 @@ export const setProxyTracker = improvedTrackManager.setProxyTracker.bind(improve
export const tracker = new TrackerPoints()
export {
type CaptureExceptionPayload,
FirebaseAdapter,
type FirebaseAdapterConfig,
type IdentifyPayload,

View File

@ -93,6 +93,33 @@ export class TrackerManager {
await this.executeTrackingForAdapters(enabledAdapters, payload)
}
/**
* Capture an exception across all enabled adapters
*/
async captureException(error: unknown, properties?: Record<string, unknown>): Promise<void> {
const enabledAdapters = this.getEnabledAdapters()
if (enabledAdapters.length === 0) {
console.warn("[TrackerManager] No enabled adapters found for exception capture")
return
}
const promises = enabledAdapters.map(async (adapter) => {
if (!adapter.captureException) return
try {
await Promise.resolve(adapter.captureException({ error, properties }))
} catch (captureError) {
console.error(
`[TrackerManager] Failed to capture exception with adapter "${adapter.getName()}":`,
captureError,
)
}
})
await Promise.allSettled(promises)
}
/**
* Identify a user across all enabled adapters
*/

View File

@ -1,46 +0,0 @@
diff --git a/esm/main/ipc.js b/esm/main/ipc.js
index 2fdc06044866a4f4c3420bd8b4a5d2788faa3e4e..7f0250b911871aa12e379d848a6a5b5668bdb282 100644
--- a/esm/main/ipc.js
+++ b/esm/main/ipc.js
@@ -130,18 +130,6 @@ function configureProtocol(client, ipcUtil, options) {
if (app.isReady()) {
throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired");
}
- const scheme = {
- scheme: ipcUtil.namespace,
- privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
- };
- protocol.registerSchemesAsPrivileged([scheme]);
- // We Proxy this function so that later user calls to registerSchemesAsPrivileged don't overwrite our custom scheme
- // eslint-disable-next-line @typescript-eslint/unbound-method
- protocol.registerSchemesAsPrivileged = new Proxy(protocol.registerSchemesAsPrivileged, {
- apply: (target, __, args) => {
- target([...args[0], scheme]);
- },
- });
const rendererStatusChanged = createRendererEventLoopBlockStatusHandler(client);
app
.whenReady()
diff --git a/main/ipc.js b/main/ipc.js
index 3cdf12e818890effadc0d5188fe104549c846b97..597ea1aabc2314e306864c562d9609541fc3edfe 100644
--- a/main/ipc.js
+++ b/main/ipc.js
@@ -130,18 +130,6 @@ function configureProtocol(client, ipcUtil, options) {
if (electron.app.isReady()) {
throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired");
}
- const scheme = {
- scheme: ipcUtil.namespace,
- privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
- };
- electron.protocol.registerSchemesAsPrivileged([scheme]);
- // We Proxy this function so that later user calls to registerSchemesAsPrivileged don't overwrite our custom scheme
- // eslint-disable-next-line @typescript-eslint/unbound-method
- electron.protocol.registerSchemesAsPrivileged = new Proxy(electron.protocol.registerSchemesAsPrivileged, {
- apply: (target, __, args) => {
- target([...args[0], scheme]);
- },
- });
const rendererStatusChanged = rendererAnr.createRendererEventLoopBlockStatusHandler(client);
electron.app
.whenReady()

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ ignorePatchFailures: false
onlyBuiltDependencies:
- "@firebase/util"
- "@sentry/cli"
- "@tsslint/core"
- "@tsslint/eslint"
- bufferutil
@ -59,7 +58,6 @@ patchedDependencies:
"@microflash/remark-callout-directives": patches/@microflash__remark-callout-directives.patch
"@mozilla/readability@0.6.0": patches/@mozilla__readability@0.6.0.patch
"@pengx17/electron-forge-maker-appimage": patches/@pengx17__electron-forge-maker-appimage.patch
"@sentry/electron": patches/@sentry__electron.patch
daisyui@4.12.24: patches/daisyui@4.12.24.patch
re-resizable@6.11.2: patches/re-resizable@6.11.2.patch
react-native-sheet-transitions: patches/react-native-sheet-transitions.patch