From cc498d17276e29797dbe30835a7ed36c39cc219c Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 20 May 2026 18:44:17 +0530 Subject: [PATCH] fix(onboarding): capture completeAndExit rejection in Sentry (#2081) (#2327) Co-authored-by: sanil-23 Co-authored-by: Claude --- .../pages/onboarding/pages/ContextPage.tsx | 13 ++++ .../pages/__tests__/ContextPage.test.tsx | 77 +++++++++++++++++++ .../onboarding/steps/ContextGatheringStep.tsx | 8 ++ .../__tests__/ContextGatheringStep.test.tsx | 24 ++++++ 4 files changed, 122 insertions(+) create mode 100644 app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx diff --git a/app/src/pages/onboarding/pages/ContextPage.tsx b/app/src/pages/onboarding/pages/ContextPage.tsx index 6728ed905..e341e2831 100644 --- a/app/src/pages/onboarding/pages/ContextPage.tsx +++ b/app/src/pages/onboarding/pages/ContextPage.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { trackEvent } from '../../../services/analytics'; @@ -15,8 +16,20 @@ const ContextPage = () => { // the final step when it runs — finish onboarding directly. onNext={() => { trackEvent('onboarding_step_complete', { step_name: 'context' }); + // The completeAndExit chain awaits four core RPCs + // (app_state_update_local_state → app_state_snapshot → + // config_set_onboarding_completed → app_state_snapshot). When any + // rejects, the user sees a silent dead "Continue to chat" button + // (#2081). The `.catch` below kept the rejection out of Sentry's + // global handlers because a handled rejection never fires + // `unhandledrejection`. Forward to Sentry explicitly so the + // dashboard can show whether #2179 (snapshot timeout) closed the + // symptom in practice. void completeAndExit().catch(error => { console.error('[onboarding:context-page] completeAndExit failed', error); + Sentry.captureException(error, { + tags: { flow: 'onboarding-complete', step: 'continue-to-chat' }, + }); }); }} onBack={() => navigate('/onboarding/skills')} diff --git a/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx b/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx new file mode 100644 index 000000000..e543fa0c8 --- /dev/null +++ b/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx @@ -0,0 +1,77 @@ +/** + * Verifies that `ContextPage` forwards a `completeAndExit` rejection into + * Sentry with the documented tags (#2081). The handled `.catch` would + * otherwise keep the failure out of `Sentry.globalHandlersIntegration`, so + * an explicit capture is the only way the dashboard sees it. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import { OnboardingContext, type OnboardingContextValue } from '../../OnboardingContext'; +import ContextPage from '../ContextPage'; + +const hoisted = vi.hoisted(() => ({ captureException: vi.fn() })); + +vi.mock('@sentry/react', () => ({ captureException: hoisted.captureException })); + +// `trackEvent` writes through to analytics → react-ga4 / Sentry. Stub it so +// the rejection-capture assertion isn't entangled with consent / GA wiring. +vi.mock('../../../../services/analytics', () => ({ trackEvent: vi.fn() })); + +// The page renders `ContextGatheringStep`, which auto-starts a heavy pipeline +// on mount when `connectedSources` includes a Composio Gmail entry. Stub it +// to a button that simply invokes `onNext` so the failure path is reachable +// without simulating the full pipeline. +vi.mock('../../steps/ContextGatheringStep', () => ({ + default: ({ onNext }: { onNext: () => void | Promise }) => ( + + ), +})); + +function renderContextPage(completeAndExit: OnboardingContextValue['completeAndExit']) { + const value: OnboardingContextValue = { + draft: { connectedSources: ['composio:gmail'] }, + setDraft: vi.fn(), + completeAndExit, + }; + return renderWithProviders( + + + + ); +} + +describe('ContextPage — onboarding-complete Sentry capture (#2081)', () => { + beforeEach(() => { + hoisted.captureException.mockReset(); + }); + + it('captures a completeAndExit rejection to Sentry with the documented tags', async () => { + const failure = new Error('app_state_snapshot timed out'); + const completeAndExit = vi.fn().mockRejectedValue(failure); + + renderContextPage(completeAndExit); + fireEvent.click(screen.getByTestId('continue-to-chat')); + + await waitFor(() => expect(hoisted.captureException).toHaveBeenCalledTimes(1)); + expect(completeAndExit).toHaveBeenCalledTimes(1); + + const [thrown, ctx] = hoisted.captureException.mock.calls[0]; + expect(thrown).toBe(failure); + expect(ctx).toEqual({ tags: { flow: 'onboarding-complete', step: 'continue-to-chat' } }); + }); + + it('does not capture anything when completeAndExit resolves', async () => { + const completeAndExit = vi.fn().mockResolvedValue(undefined); + + renderContextPage(completeAndExit); + fireEvent.click(screen.getByTestId('continue-to-chat')); + + // Let the resolved promise settle. + await waitFor(() => expect(completeAndExit).toHaveBeenCalledTimes(1)); + expect(hoisted.captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 06a14b666..043538eaf 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -12,6 +12,7 @@ * External calls still go through core (auth, proxy, billing). Only the * stage-by-stage orchestration lives in the renderer. */ +import * as Sentry from '@sentry/react'; import { useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -372,6 +373,13 @@ const ContextGatheringStep = ({ const t = setTimeout(() => { void Promise.resolve(onNext()).catch(e => { console.warn('[onboarding:context] auto-advance failed', e); + // Mirrors the manual click capture in ContextPage so the auto- + // advance failure mode is not a Sentry blind spot (#2081). The + // step tag distinguishes it from `continue-to-chat` clicks in + // the dashboard. + Sentry.captureException(e, { + tags: { flow: 'onboarding-complete', step: 'auto-advance' }, + }); setHasError(true); }); }, 800); diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index e71d274ba..073bbfa1a 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -9,11 +9,13 @@ const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/ const testCoreRpcConnection = vi.hoisted(() => vi.fn(async () => ({ ok: true, status: 200 }) as Response) ); +const captureException = vi.hoisted(() => vi.fn()); vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc, getCoreRpcUrl, testCoreRpcConnection, })); +vi.mock('@sentry/react', () => ({ captureException })); describe('ContextGatheringStep', () => { beforeEach(() => { @@ -21,6 +23,7 @@ describe('ContextGatheringStep', () => { getCoreRpcUrl.mockClear(); testCoreRpcConnection.mockClear(); testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response); + captureException.mockReset(); }); it('no-Gmail branch: auto-navigates without any RPC', async () => { @@ -289,6 +292,27 @@ describe('ContextGatheringStep', () => { // just verify the friendly message is shown }); + it('captures auto-advance onNext rejection to Sentry with the documented tags (#2081)', async () => { + vi.useFakeTimers(); + const failure = new Error('app_state_snapshot timed out'); + const onNext = vi.fn().mockRejectedValue(failure); + + renderWithProviders(); + + // No-Gmail branch finishes synchronously, then auto-advance fires after 800ms. + await act(async () => { + await vi.advanceTimersByTimeAsync(900); + }); + + expect(onNext).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(captureException).toHaveBeenCalledTimes(1)); + const [thrown, ctx] = captureException.mock.calls[0]; + expect(thrown).toBe(failure); + expect(ctx).toEqual({ tags: { flow: 'onboarding-complete', step: 'auto-advance' } }); + + vi.useRealTimers(); + }); + // -------------------------------------------------------------------------- // #2156 — slow-but-alive snapshot / save_profile path // --------------------------------------------------------------------------