diff --git a/AGENTS.md b/AGENTS.md index ae80cf16d3..0ec7662798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,50 @@ Enable the **Next DevTools MCP server** so agents can query live routes, errors, --- +## Sentry Error Handling + +When addressing issues reported by Sentry, **always attempt to fix the root cause first**. Silencing errors should only be considered as a last resort when fixing is genuinely not possible. + +### Default Workflow + +1. **Fix the code to prevent the issue** (primary action) + * Analyze the error stack trace and identify the root cause + * Implement a proper fix that addresses the underlying problem + * Add appropriate error handling, validation, or defensive checks + * Update tests to cover the fix + +2. **Ask about silencing only if fixing is not possible** (fallback) + * Only proceed to silencing if the error is genuinely unfixable, such as: + * Third-party library errors that cannot be patched + * Browser extension noise (e.g., injected scripts) + * Known browser bugs that cannot be worked around + * Expected errors that are already handled gracefully in the UI + +### Where Silencing Happens + +If silencing is necessary, errors are filtered in the `beforeSend` callback of Sentry initialization: + +* **Client-side**: [`instrumentation-client.ts`](instrumentation-client.ts) — contains `noisyPatterns`, `referenceErrors`, and `filenameExceptions` arrays +* **Server-side**: [`sentry.server.config.ts`](sentry.server.config.ts) — server runtime configuration +* **Edge runtime**: [`sentry.edge.config.ts`](sentry.edge.config.ts) — edge runtime configuration + +### Examples + +**Fixable (default action):** +* Null reference errors → add null checks or optional chaining +* Type errors → fix type definitions or add runtime validation +* Network errors → implement retry logic or better error handling +* Missing dependencies → add proper dependency arrays or fix imports + +**Non-fixable (ask about silencing):** +* Browser extension injecting scripts (`inpage.js` errors) +* Known browser bugs (e.g., `ResizeObserver loop limit exceeded` in some browsers) +* Third-party library errors that cannot be patched without forking + +This aligns with the "Fix with modernization" principle: prioritize meaningful fixes over suppressing symptoms. + +--- + ## Next.js 16: What this means for agents * **Proxy instead of Middleware:** `middleware.ts` is **renamed to** `proxy.ts` (Node runtime). If you touch request‑boundary logic, ensure the file and exported function are named `proxy`. Legacy `middleware.ts` still exists for edge‑only cases but our default is `proxy.ts`. ([Next.js][6]) @@ -182,6 +226,7 @@ If you add or modify `proxy.ts`, keep it at the root (or `src/`) alongside `app/ * Tests live in `__tests__/` or `ComponentName.test.tsx`. * Mock external dependencies and APIs in tests. * When parsing Seize URLs (or similar), **do not** fall back to placeholder origins; fail fast if base origin is unavailable. +* **React imports:** Prefer direct named imports (`useMemo`, `useRef`, `FC`, etc.) over `React.` namespace usage (`React.useMemo`, `React.useRef`, `React.FC`, etc.). Import hooks and types directly: `import { useMemo, useRef, FC, memo } from "react"` rather than `import React from "react"` and using `React.useMemo`. --- diff --git a/__tests__/components/notifications/NotificationsContext.test.tsx b/__tests__/components/notifications/NotificationsContext.test.tsx index cff36f090d..3c9812b93c 100644 --- a/__tests__/components/notifications/NotificationsContext.test.tsx +++ b/__tests__/components/notifications/NotificationsContext.test.tsx @@ -1,9 +1,12 @@ -import React from "react"; -import { renderHook, act } from "@testing-library/react"; import { NotificationsProvider, useNotificationsContext, } from "@/components/notifications/NotificationsContext"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React from "react"; + +const push = jest.fn(); +const mockUseRouter = jest.fn(() => ({ push })); jest.mock("@/hooks/useCapacitor", () => () => ({ isCapacitor: true, @@ -11,15 +14,36 @@ jest.mock("@/hooks/useCapacitor", () => () => ({ })); jest.mock("next/navigation", () => ({ __esModule: true, - useRouter: jest.fn(() => ({ push: jest.fn() })), + useRouter: () => mockUseRouter(), })); jest.mock("@/components/auth/Auth", () => ({ - useAuth: () => ({ connectedProfile: null }), + useAuth: () => ({ connectedProfile: { id: "test-profile-id" } }), })); jest.mock("@/services/api/common-api", () => ({ + commonApiPost: jest.fn().mockResolvedValue({}), commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue({}), })); +jest.mock("@capacitor/push-notifications", () => { + return { + PushNotifications: { + removeAllListeners: jest.fn().mockResolvedValue(undefined), + addListener: jest.fn(), + requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }), + register: jest.fn().mockResolvedValue(undefined), + getDeliveredNotifications: jest + .fn() + .mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }), + removeDeliveredNotifications: jest.fn().mockResolvedValue(undefined), + removeAllDeliveredNotifications: jest.fn().mockResolvedValue(undefined), + }, + PushNotificationSchema: {}, + }; +}); +jest.mock("@capacitor/device", () => ({ + Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) }, +})); + const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); @@ -44,69 +68,66 @@ describe("NotificationsContext", () => { }); }); -jest.mock("@capacitor/push-notifications", () => ({ - PushNotifications: { - removeAllListeners: jest.fn(), - addListener: jest.fn(), - requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }), - register: jest.fn(), - getDeliveredNotifications: jest - .fn() - .mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }), - removeDeliveredNotifications: jest.fn(), - removeAllDeliveredNotifications: jest.fn(), - }, -})); - -jest.mock("@capacitor/device", () => ({ - Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) }, -})); - it("removes notifications when functions called", async () => { + const { PushNotifications } = require("@capacitor/push-notifications"); const { result } = renderHook(() => useNotificationsContext(), { wrapper }); + + await waitFor(() => { + expect(PushNotifications.removeAllListeners).toHaveBeenCalled(); + }); + await act(async () => { await result.current.removeWaveDeliveredNotifications("w1"); await result.current.removeAllDeliveredNotifications(); }); - const { PushNotifications } = require("@capacitor/push-notifications"); expect(PushNotifications.getDeliveredNotifications).toHaveBeenCalled(); expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled(); expect(PushNotifications.removeAllDeliveredNotifications).toHaveBeenCalled(); }); describe("push notification action handling", () => { + beforeEach(() => { + push.mockClear(); + const { PushNotifications } = require("@capacitor/push-notifications"); + PushNotifications.addListener.mockClear(); + PushNotifications.removeDeliveredNotifications.mockClear(); + }); + it("redirects based on notification data", async () => { - const push = jest.fn(); - jest - .spyOn(require("next/navigation"), "useRouter") - .mockReturnValue({ push } as any); - - const addListenerMock = jest.fn((evt, cb) => { - if (evt === "pushNotificationActionPerformed") { - setTimeout( - () => - cb({ - notification: { - data: { - redirect: "profile", - handle: "abc", - notification_id: "1", - }, - }, - }), - 0 - ); - } + const { PushNotifications } = require("@capacitor/push-notifications"); + const { result } = renderHook(() => useNotificationsContext(), { wrapper }); + + await waitFor(() => { + expect(PushNotifications.addListener).toHaveBeenCalled(); }); - const { PushNotifications } = require("@capacitor/push-notifications"); - PushNotifications.addListener = addListenerMock; + const addListenerCalls = PushNotifications.addListener.mock.calls; + const actionPerformedCall = addListenerCalls.find( + (call: any[]) => call[0] === "pushNotificationActionPerformed" + ); + const callback = actionPerformedCall?.[1]; + + expect(callback).toBeDefined(); - const { result } = renderHook(() => useNotificationsContext(), { wrapper }); await act(async () => { + if (callback) { + await callback({ + notification: { + data: { + redirect: "profile", + handle: "abc", + notification_id: "1", + }, + }, + }); + } await new Promise((r) => setTimeout(r, 100)); }); - expect(push).toHaveBeenCalledWith("/abc"); + + await waitFor(() => { + expect(push).toHaveBeenCalledWith("/abc"); + }); + expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled(); }); }); diff --git a/__tests__/components/waves/CreateDropStormParts.test.tsx b/__tests__/components/waves/CreateDropStormParts.test.tsx index 661319c736..7510508104 100644 --- a/__tests__/components/waves/CreateDropStormParts.test.tsx +++ b/__tests__/components/waves/CreateDropStormParts.test.tsx @@ -1,20 +1,26 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import CreateDropStormParts from '@/components/waves/CreateDropStormParts'; -import { AuthContext } from '@/components/auth/Auth'; +import { AuthContext } from "@/components/auth/Auth"; +import CreateDropStormParts from "@/components/waves/CreateDropStormParts"; +import { render, screen } from "@testing-library/react"; -jest.mock('@/components/waves/CreateDropStormPart', () => ({ +jest.mock("@/components/waves/CreateDropStormPart", () => ({ __esModule: true, default: ({ partIndex }: any) =>
, })); +jest.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: any) => children, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + }, +})); + const authValue = { - connectedProfile: { handle: 'user', pfp: 'img.png', level: 1, cic: 0 }, + connectedProfile: { handle: "user", pfp: "img.png", level: 1, cic: 0 }, } as any; -describe('CreateDropStormParts', () => { - it('renders parts with profile info', () => { - const parts = [{ content: 'a' }, { content: 'b' }] as any; +describe("CreateDropStormParts", () => { + it("renders parts with profile info", () => { + const parts = [{ content: "a" }, { content: "b" }] as any; render( { /> ); - expect(screen.getByTestId('part-0')).toBeInTheDocument(); - expect(screen.getByTestId('part-1')).toBeInTheDocument(); - expect(screen.getByRole('img')).toHaveAttribute('src', 'img.png'); - expect(screen.getByRole('link')).toHaveAttribute('href', '/user'); + expect(screen.getByTestId("part-0")).toBeInTheDocument(); + expect(screen.getByTestId("part-1")).toBeInTheDocument(); + expect(screen.getByRole("img")).toHaveAttribute("src", "img.png"); + expect(screen.getByRole("link")).toHaveAttribute("href", "/user"); }); }); diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx index 1cc23a41ef..25528fbcd9 100644 --- a/components/waves/CreateDropContent.tsx +++ b/components/waves/CreateDropContent.tsx @@ -609,23 +609,27 @@ const CreateDropContent: React.FC = ({ allMentions: ApiDropMentionedUser[], allNfts: ReferencedNft[] ): CreateDropConfig => { - const parts = ensurePartsWithFallback( - [ - ...(drop?.parts ?? []), - { - content: markdown?.length ? markdown : null, - quoted_drop: - activeDrop?.action === ActiveDropAction.QUOTE - ? { - drop_id: activeDrop.drop.id, - drop_part_id: activeDrop.partId, - } - : null, - media: files, - }, - ], - hasMetadata - ); + const hasPartsInDrop = (drop?.parts.length ?? 0) > 0; + const hasCurrentContent = !!(markdown?.trim().length || files.length); + + const newParts = hasPartsInDrop && !hasCurrentContent + ? drop?.parts ?? [] + : [ + ...(drop?.parts ?? []), + { + content: markdown?.length ? markdown : null, + quoted_drop: + activeDrop?.action === ActiveDropAction.QUOTE + ? { + drop_id: activeDrop.drop.id, + drop_part_id: activeDrop.partId, + } + : null, + media: files, + }, + ]; + + const parts = ensurePartsWithFallback(newParts, hasMetadata); return { title: null, @@ -869,6 +873,15 @@ const CreateDropContent: React.FC = ({ ) { return; } + + const hasPartsInDrop = (drop?.parts.length ?? 0) > 0; + const hasCurrentContent = !!(getMarkdown?.trim().length || files.length); + + if (hasPartsInDrop && hasCurrentContent) { + finalizeAndAddDropPart(); + return; + } + await prepareAndSubmitDrop(getUpdatedDrop()); }; diff --git a/components/waves/CreateDropStormParts.tsx b/components/waves/CreateDropStormParts.tsx index 5f8c439fdf..f697fe8d99 100644 --- a/components/waves/CreateDropStormParts.tsx +++ b/components/waves/CreateDropStormParts.tsx @@ -1,16 +1,24 @@ "use client"; -import React from "react"; import { CreateDropPart, ReferencedNft } from "@/entities/IDrop"; import { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; -import { AuthContext } from "../auth/Auth"; import { cicToType } from "@/helpers/Helpers"; -import Link from "next/link"; -import CreateDropStormPart from "./CreateDropStormPart"; import { AnimatePresence, motion } from "framer-motion"; +import Link from "next/link"; +import { + FC, + memo, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AuthContext } from "../auth/Auth"; import UserCICAndLevel, { UserCICAndLevelSize, } from "../user/utils/UserCICAndLevel"; +import CreateDropStormPart from "./CreateDropStormPart"; interface CreateDropStormPartsProps { parts: CreateDropPart[]; @@ -19,15 +27,53 @@ interface CreateDropStormPartsProps { onRemovePart: (partIndex: number) => void; } -const CreateDropStormParts: React.FC = ({ +const CreateDropStormParts: FC = ({ parts, mentionedUsers, referencedNfts, onRemovePart, }) => { - const { connectedProfile } = React.useContext(AuthContext); + const { connectedProfile } = useContext(AuthContext); const cicType = cicToType(connectedProfile?.cic ?? 0); + const partIdCounterRef = useRef(0); + const [partIdsMap, setPartIdsMap] = useState>(new Map()); + + useEffect(() => { + setPartIdsMap((prevMap) => { + const newMap = new Map(prevMap); + let changed = false; + + parts.forEach((part, index) => { + if (!part.quoted_drop) { + if (!newMap.has(index)) { + newMap.set(index, `part-${partIdCounterRef.current++}`); + changed = true; + } + } + }); + + const maxIndex = parts.length - 1; + Array.from(newMap.keys()).forEach((key) => { + if (key > maxIndex) { + newMap.delete(key); + changed = true; + } + }); + + return changed ? newMap : prevMap; + }); + }, [parts]); + + const partKeys = useMemo(() => { + return parts.map((part, index) => { + if (part.quoted_drop) { + return `quoted-${part.quoted_drop.drop_id}-${part.quoted_drop.drop_part_id}`; + } + return partIdsMap.get(index) ?? `part-fallback-${index}`; + }); + }, [parts, partIdsMap]); + return (
@@ -61,11 +107,11 @@ const CreateDropStormParts: React.FC = ({
- - +
+ {parts.map((part, partIndex) => ( = ({ /> ))} - - + +
@@ -88,4 +134,4 @@ const CreateDropStormParts: React.FC = ({ ); }; -export default React.memo(CreateDropStormParts); +export default memo(CreateDropStormParts); diff --git a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts index 4e090676f4..e68791c8ef 100644 --- a/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts +++ b/components/waves/drops/wave-drops-all/hooks/useWaveDropsClipboard.ts @@ -1122,7 +1122,7 @@ export const useWaveDropsClipboard = ({ } const handleKeyDown = (event: KeyboardEvent) => { - if (event.key.toLowerCase() !== "c") { + if (typeof event.key !== "string" || event.key.toLowerCase() !== "c") { return; } diff --git a/config/sentryProbes.ts b/config/sentryProbes.ts index 6e4091552a..8ca54ffd6c 100644 --- a/config/sentryProbes.ts +++ b/config/sentryProbes.ts @@ -1,3 +1,5 @@ +import type { Event, EventHint } from "@sentry/nextjs"; + const PROBE_PATTERNS = [ ".jsp", ".php", @@ -7,8 +9,8 @@ const PROBE_PATTERNS = [ "/wp-admin", "/wp-login", "/cgi-bin/", - "/manager/html", // Tomcat - "/admin/login.jsp", // common Java probe + "/manager/html", + "/admin/login.jsp", ]; const probeTags = { @@ -16,7 +18,111 @@ const probeTags = { probe_type: "generic-exploit-scan", }; -export function tagSecurityProbes(event: any) { +const CONNECTION_ERROR_PATTERNS = ["aborted", "ECONNRESET", "socket hang up"]; + +const HTTP_SERVER_STACK_PATTERNS = [ + "_http_server", + "abortIncoming", + "socketOnClose", +]; + +function isConnectionError(message: string): boolean { + const normalized = message.toLowerCase(); + return CONNECTION_ERROR_PATTERNS.some((pattern) => + normalized.includes(pattern.toLowerCase()) + ); +} + +function isFrameWithPaths( + frame: unknown +): frame is { filename?: string; abs_path?: string } { + return ( + typeof frame === "object" && + frame !== null && + ("filename" in frame || "abs_path" in frame) + ); +} + +function isMonitoringRoute(url: string, stacktrace: unknown[]): boolean { + if (url.includes("/monitoring")) { + return true; + } + return stacktrace.some((frame) => { + if (!isFrameWithPaths(frame)) { + return false; + } + return ( + frame.filename?.includes("monitoring") || + frame.abs_path?.includes("monitoring") + ); + }); +} + +function hasHttpServerStack(stack: string): boolean { + return HTTP_SERVER_STACK_PATTERNS.some((pattern) => stack.includes(pattern)); +} + +function checkFirstErrorPath( + event: Event, + message: string, + value: { stacktrace?: { frames?: unknown[] } } +): boolean { + if (!isConnectionError(message)) { + return false; + } + + const url = event.request?.url || ""; + const stacktrace = value?.stacktrace?.frames || []; + + return isMonitoringRoute(url, stacktrace); +} + +function checkSecondErrorPath( + event: Event, + message: string, + hint?: EventHint +): boolean { + if (message !== "aborted" || !hint?.originalException) { + return false; + } + + if (!(hint.originalException instanceof Error)) { + return false; + } + + const stack = hint.originalException.stack || ""; + if (!hasHttpServerStack(stack)) { + return false; + } + + const url = event.request?.url || ""; + return url.includes("/monitoring"); +} + +export function filterTunnelRouteErrors( + event: T, + hint?: EventHint +): T | null { + const value = event.exception?.values?.[0]; + const message = value?.value || ""; + const errorType = value?.type || ""; + + if (typeof message === "string") { + if (checkFirstErrorPath(event, message, value || {})) { + return null; + } + } + + if (errorType === "Error" && typeof message === "string") { + if (checkSecondErrorPath(event, message, hint)) { + return null; + } + } + + return event; +} + +export function tagSecurityProbes(event: T): T { try { const url = (event?.request?.url || "").toLowerCase(); diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 5857046625..cd053329d4 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,6 +13,148 @@ const sentryEnabled = !!publicEnv.SENTRY_DSN; const isProduction = publicEnv.NODE_ENV === "production"; const dsn = publicEnv.SENTRY_DSN; +const noisyPatterns = [ + "EmptyRanges", + "ResizeObserver loop limit exceeded", + "Non-Error promise rejection captured", +]; + +const referenceErrors = ["__firefox__"]; + +const filenameExceptions = ["inpage.js"]; + +const URL_REGEX = /\(([^)]+?)\)/; + +function getFallbackMessage(hint?: Sentry.EventHint): string { + if (typeof hint?.originalException === "string") { + return hint.originalException; + } + if (hint?.originalException instanceof Error) { + return hint.originalException.message; + } + return ""; +} + +function shouldFilterNoisyPatterns(message: string): boolean { + return noisyPatterns.some((p) => message.includes(p)); +} + +function shouldFilterReferenceErrors( + message: string, + errorType: string | undefined +): boolean { + if (errorType !== "ReferenceError" && errorType !== "TypeError") { + return false; + } + return referenceErrors.some((p) => message.includes(p)); +} + +function shouldFilterFilenameExceptions( + frames: Sentry.StackFrame[] | undefined +): boolean { + if (!frames) { + return false; + } + return frames.some((frame) => + filenameExceptions.some( + (pattern) => + frame?.filename?.includes(pattern) || frame?.abs_path?.includes(pattern) + ) + ); +} + +function shouldFilterEvent( + event: Sentry.Event, + hint?: Sentry.EventHint +): boolean { + const value = event.exception?.values?.[0]; + const fallbackMessage = getFallbackMessage(hint); + const message = value?.value ?? fallbackMessage; + + if (typeof message === "string") { + if (shouldFilterNoisyPatterns(message)) { + return true; + } + if (shouldFilterReferenceErrors(message, value?.type)) { + return true; + } + } + + const frames = event.exception?.values?.[0]?.stacktrace?.frames; + return shouldFilterFilenameExceptions(frames); +} + +function handleIndexedDBError(event: Sentry.Event): void { + event.level = "warning"; + event.tags = { + ...event.tags, + errorType: "indexeddb", + handled: true, + }; + event.fingerprint = ["indexeddb-connection-lost"]; +} + +function extractUrlFromError(error: TypeError, event: Sentry.Event): string { + const urlMatch = URL_REGEX.exec(error.message.slice(0, 2048)); + if (urlMatch?.[1]) { + return urlMatch[1]; + } + + const fetchBreadcrumb = event.breadcrumbs?.find( + (crumb) => crumb.category === "fetch" || crumb.type === "http" + ); + if (fetchBreadcrumb?.data?.url) { + return fetchBreadcrumb.data.url; + } + if (event.request?.url) { + return event.request.url; + } + return "unknown"; +} + +function isNetworkError(errorMessage: string): boolean { + const normalized = errorMessage.toLowerCase(); + return ( + normalized.includes("failed to fetch") || + normalized.includes("load failed") || + normalized.includes("networkerror") || + normalized.includes("network error") || + normalized.includes("network request failed") || + /\bnetwork\b/.test(normalized) + ); +} + +function handleNetworkError( + event: Sentry.Event, + error: TypeError, + value: Sentry.Exception | undefined +): void { + if (!isNetworkError(error.message)) { + return; + } + + const url = extractUrlFromError(error, event); + const normalized = error.message.toLowerCase(); + const transformedMessage = normalized.includes("network") + ? `Network error: ${error.message} (${url})` + : `Network request failed. Please check your connection and try again. (${url})`; + + if (value) { + value.value = transformedMessage; + } + if (event.message) { + event.message = transformedMessage; + } + + event.level = "warning"; + event.tags = { + ...event.tags, + errorType: "network", + handled: true, + }; + event.fingerprint = ["network-error"]; +} + Sentry.init({ dsn: sentryEnabled ? dsn : undefined, enabled: sentryEnabled, @@ -30,39 +172,19 @@ Sentry.init({ sendDefaultPii: true, beforeSend(event, hint) { - const value = event.exception?.values?.[0]; - - let fallbackMessage = ""; - if (typeof hint?.originalException === "string") { - fallbackMessage = hint.originalException; - } else if (hint?.originalException instanceof Error) { - fallbackMessage = hint.originalException.message; + if (shouldFilterEvent(event, hint)) { + return null; } - const message = value?.value ?? fallbackMessage; - - if (typeof message === "string") { - const noisyPatterns = [ - "EmptyRanges", - "ResizeObserver loop limit exceeded", - "Non-Error promise rejection captured", - ]; + const error = hint?.originalException ?? hint?.syntheticException; + const value = event.exception?.values?.[0]; - if (noisyPatterns.some((p) => message.includes(p))) { - return null; - } + if (error && isIndexedDBError(error)) { + handleIndexedDBError(event); } - const error = hint.originalException || hint.syntheticException; - - if (error && isIndexedDBError(error)) { - event.level = "warning"; - event.tags = { - ...event.tags, - errorType: "indexeddb", - handled: true, - }; - event.fingerprint = ["indexeddb-connection-lost"]; + if (error instanceof TypeError) { + handleNetworkError(event, error, value); } return event; diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 36a1dc138a..9c804c98d9 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -5,7 +5,10 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import { publicEnv } from "@/config/env"; -import { tagSecurityProbes } from "@/config/sentryProbes"; +import { + filterTunnelRouteErrors, + tagSecurityProbes, +} from "@/config/sentryProbes"; import * as Sentry from "@sentry/nextjs"; const dsn = publicEnv.SENTRY_DSN; @@ -26,7 +29,11 @@ Sentry.init({ // ------------------------------------------------------------ // Handle obvious bot / exploit probes more gently (edge) // ------------------------------------------------------------ - beforeSend(event) { - return tagSecurityProbes(event); + beforeSend(event: Sentry.ErrorEvent, hint: Sentry.EventHint) { + const filtered = filterTunnelRouteErrors(event, hint); + if (filtered === null) { + return null; + } + return tagSecurityProbes(filtered); }, }); diff --git a/sentry.server.config.ts b/sentry.server.config.ts index d3f67b0ea2..9e966ffd03 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -3,7 +3,10 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import { publicEnv } from "@/config/env"; -import { tagSecurityProbes } from "@/config/sentryProbes"; +import { + filterTunnelRouteErrors, + tagSecurityProbes, +} from "@/config/sentryProbes"; import * as Sentry from "@sentry/nextjs"; const dsn = publicEnv.SENTRY_DSN; @@ -26,7 +29,11 @@ Sentry.init({ // ------------------------------------------------------------ // Handle obvious bot / exploit probes more gently // ------------------------------------------------------------ - beforeSend(event) { - return tagSecurityProbes(event); + beforeSend(event, hint) { + const filtered = filterTunnelRouteErrors(event, hint); + if (filtered === null) { + return null; + } + return tagSecurityProbes(filtered); }, }); diff --git a/services/api/common-api.ts b/services/api/common-api.ts index 0c81255ebf..4319687f52 100644 --- a/services/api/common-api.ts +++ b/services/api/common-api.ts @@ -63,22 +63,53 @@ const executeApiRequest = async ( signal?: AbortSignal, parseJson: boolean = true ): Promise => { - const res = await fetch(url, { - method, - headers, - body, - signal, - }); - - if (!res.ok) { - return handleApiError(res); - } + try { + const res = await fetch(url, { + method, + headers, + body, + signal, + }); - if (!parseJson) { - return undefined as T; - } + if (!res.ok) { + return handleApiError(res); + } - return res.json(); + if (!parseJson) { + return undefined as T; + } + + try { + return await res.json(); + } catch (jsonError) { + throw new Error( + `Failed to parse response as JSON from ${url}: ${ + jsonError instanceof Error ? jsonError.message : String(jsonError) + }` + ); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + + if (error instanceof TypeError) { + const errorMessage = error.message.toLowerCase(); + if ( + errorMessage.includes("load failed") || + errorMessage.includes("failed to fetch") + ) { + throw new Error( + `Network request failed. Please check your connection and try again. (${url})` + ); + } + if (errorMessage.includes("network")) { + throw new Error(`Network error: ${error.message} (${url})`); + } + } + + throw error; + } }; export const commonApiFetch = async >(param: {