fix(onboarding): capture completeAndExit rejection in Sentry (#2081) (#2327)

Co-authored-by: sanil-23 <sanil@alphahuman.xyz>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-05-20 18:44:17 +05:30
committed by GitHub
co-authored by sanil-23 Claude
parent 997be4eef2
commit cc498d1727
4 changed files with 122 additions and 0 deletions
@@ -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')}
@@ -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<void> }) => (
<button data-testid="continue-to-chat" onClick={() => onNext()}>
Continue to chat
</button>
),
}));
function renderContextPage(completeAndExit: OnboardingContextValue['completeAndExit']) {
const value: OnboardingContextValue = {
draft: { connectedSources: ['composio:gmail'] },
setDraft: vi.fn(),
completeAndExit,
};
return renderWithProviders(
<OnboardingContext.Provider value={value}>
<ContextPage />
</OnboardingContext.Provider>
);
}
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();
});
});
@@ -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);
@@ -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(<ContextGatheringStep connectedSources={['notion']} onNext={onNext} />);
// 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
// --------------------------------------------------------------------------