diff --git a/ai/tools/real-world-performance.ts b/ai/tools/real-world-performance.ts index 058b41a..a2ec50d 100644 --- a/ai/tools/real-world-performance.ts +++ b/ai/tools/real-world-performance.ts @@ -7,6 +7,19 @@ import type { RealWorldPerformanceOutput, } from "@/types/real-world-performance"; +class RecoverableCruxApiError extends Error { + constructor(message: string) { + super(message); + this.name = "RecoverableCruxApiError"; + } +} + +function isRecoverableCruxApiError( + error: unknown, +): error is RecoverableCruxApiError { + return error instanceof RecoverableCruxApiError; +} + const realWorldPerformanceInputSchema = z.object({ url: z.string().describe("The URL to analyze for real-world performance"), devices: z @@ -66,7 +79,7 @@ async function fetchPerformanceData( if (!response.ok) { if (response.status === 400) { - throw new Error( + throw new RecoverableCruxApiError( `CrUX API failed for ${strategy} (${response.status}): URL may be invalid, not in CrUX dataset, or contains query parameters. URL: ${url}`, ); } @@ -188,7 +201,11 @@ async function getRealWorldPerformance( const results = await Promise.allSettled(promises); // Track errors for better error messages - const errors: { device: string; error: string }[] = []; + const errors: { + device: string; + error: string; + recoverable: boolean; + }[] = []; const mobileData = deviceOrder.includes("mobile") ? (() => { @@ -205,19 +222,29 @@ async function getRealWorldPerformance( mobileResult.reason instanceof Error ? mobileResult.reason.message : "Unknown error"; - errors.push({ device: "mobile", error: errorMessage }); + const isRecoverableError = isRecoverableCruxApiError( + mobileResult.reason, + ); + errors.push({ + device: "mobile", + error: errorMessage, + recoverable: isRecoverableError, + }); Sentry.logger.warn("Mobile CrUX data fetch failed", { url: normalizedUrl, error: errorMessage, + recoverable: isRecoverableError, }); - Sentry.captureException(mobileResult.reason, { - tags: { - component: "real-world-performance-tool", - operation: "fetchPerformanceData", - strategy: "mobile", - }, - extra: { url: normalizedUrl }, - }); + if (!isRecoverableError) { + Sentry.captureException(mobileResult.reason, { + tags: { + component: "real-world-performance-tool", + operation: "fetchPerformanceData", + strategy: "mobile", + }, + extra: { url: normalizedUrl }, + }); + } return null; })() : null; @@ -237,19 +264,29 @@ async function getRealWorldPerformance( desktopResult.reason instanceof Error ? desktopResult.reason.message : "Unknown error"; - errors.push({ device: "desktop", error: errorMessage }); + const isRecoverableError = isRecoverableCruxApiError( + desktopResult.reason, + ); + errors.push({ + device: "desktop", + error: errorMessage, + recoverable: isRecoverableError, + }); Sentry.logger.warn("Desktop CrUX data fetch failed", { url: normalizedUrl, error: errorMessage, + recoverable: isRecoverableError, }); - Sentry.captureException(desktopResult.reason, { - tags: { - component: "real-world-performance-tool", - operation: "fetchPerformanceData", - strategy: "desktop", - }, - extra: { url: normalizedUrl }, - }); + if (!isRecoverableError) { + Sentry.captureException(desktopResult.reason, { + tags: { + component: "real-world-performance-tool", + operation: "fetchPerformanceData", + strategy: "desktop", + }, + extra: { url: normalizedUrl }, + }); + } return null; })() : null; @@ -310,11 +347,11 @@ async function getRealWorldPerformance( const requestedOnlyDesktop = !devices.includes("mobile") && devices.includes("desktop"); - // Only throw if we had actual API errors (not just empty metrics) - const hasActualErrors = errors.length > 0; + // Invalid / unsupported CrUX URLs should degrade to the empty-state UI. + const hasBlockingErrors = errors.some((error) => !error.recoverable); if ( - hasActualErrors && + hasBlockingErrors && ((requestedBothDevices && !hasMobileData && !hasDesktopData) || (requestedOnlyMobile && !hasMobileData) || (requestedOnlyDesktop && !hasDesktopData))