From 00bb53de3a30259d36458328e6dad67b1322d85b Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Mon, 4 May 2026 23:26:00 +0530 Subject: [PATCH] feat(sentry): staging-only "Trigger Sentry Test" button (#1072) (#1183) --- .../settings/panels/DeveloperOptionsPanel.tsx | 69 ++++++ .../__tests__/DeveloperOptionsPanel.test.tsx | 106 +++++++++ app/src/services/__tests__/analytics.test.ts | 201 ++++++++++++++++++ app/src/services/analytics.ts | 67 +++++- 4 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx create mode 100644 app/src/services/__tests__/analytics.test.ts diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index c7c193887..060febd3f 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -1,3 +1,7 @@ +import { useState } from 'react'; + +import { triggerSentryTestEvent } from '../../../services/analytics'; +import { APP_ENVIRONMENT } from '../../../utils/config'; import SettingsHeader from '../components/SettingsHeader'; import SettingsMenuItem from '../components/SettingsMenuItem'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -134,8 +138,72 @@ const developerItems = [ }, ]; +type SentryTestStatus = + | { kind: 'idle' } + | { kind: 'sending' } + | { kind: 'sent'; eventId: string | undefined } + | { kind: 'error'; message: string }; + +// Staging-only Sentry pipeline check (issue #1072). Removed once the +// staging dashboard confirms events are landing with the right tags. +const SentryTestRow = () => { + const [status, setStatus] = useState({ kind: 'idle' }); + + const onClick = async () => { + setStatus({ kind: 'sending' }); + try { + const eventId = await triggerSentryTestEvent(); + setStatus({ kind: 'sent', eventId }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }; + + return ( +
+
+
+
Trigger Sentry Test (staging)
+
+ Fires a tagged error to verify the Sentry pipeline. Issue #1072 — remove after + verification. +
+
+ +
+ {/* + * Single live region so screen readers announce the result when + * status flips from `sending` to `sent` / `error`. `aria-live=polite` + * waits for any in-flight speech to finish; `aria-atomic` makes the + * reader re-read the whole region rather than only the diff. + */} +
+ {status.kind === 'sent' && ( + + Event sent.{' '} + {status.eventId ? ( + id: {status.eventId} + ) : ( + (no id — Sentry disabled in this build) + )} + + )} + {status.kind === 'error' && ( + Failed: {status.message} + )} +
+
+ ); +}; + const DeveloperOptionsPanel = () => { const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation(); + const showSentryTest = APP_ENVIRONMENT === 'staging'; return (
@@ -147,6 +215,7 @@ const DeveloperOptionsPanel = () => { />
+ {showSentryTest && } {developerItems.map((item, index) => ( ({ + trigger: vi.fn(), + appEnvironment: 'staging' as 'staging' | 'production' | 'development', +})); + +vi.mock('../../../../services/analytics', () => ({ triggerSentryTestEvent: hoisted.trigger })); + +vi.mock('../../../../utils/config', () => ({ + get APP_ENVIRONMENT() { + return hoisted.appEnvironment; + }, +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateToSettings: vi.fn(), + navigateBack: vi.fn(), + breadcrumbs: [], + }), +})); + +async function importPanel() { + const mod = await import('../DeveloperOptionsPanel'); + return mod.default; +} + +describe('DeveloperOptionsPanel — Sentry test row', () => { + beforeEach(() => { + hoisted.trigger.mockReset(); + hoisted.appEnvironment = 'staging'; + }); + + test('does not render the row in production builds', async () => { + hoisted.appEnvironment = 'production'; + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + expect(screen.queryByText(/Trigger Sentry Test/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /Send test event/i })).toBeNull(); + }); + + test('renders the staging row and fires the helper on click', async () => { + hoisted.trigger.mockResolvedValue('event-id-xyz'); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + expect(screen.getByText(/Trigger Sentry Test/i)).toBeInTheDocument(); + const button = screen.getByRole('button', { name: /Send test event/i }); + + fireEvent.click(button); + + await waitFor(() => { + expect(hoisted.trigger).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByText(/Event sent\./i)).toBeInTheDocument(); + }); + expect(screen.getByText(/event-id-xyz/)).toBeInTheDocument(); + + // Status updates must announce via an accessible live region — without + // role="status" + aria-live, screen readers stay silent on click. + const live = screen.getByRole('status'); + expect(live).toHaveAttribute('aria-live', 'polite'); + expect(live).toHaveTextContent(/Event sent.*event-id-xyz/); + }); + + test('shows "no id" branch when the helper resolves with undefined', async () => { + hoisted.trigger.mockResolvedValue(undefined); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + fireEvent.click(screen.getByRole('button', { name: /Send test event/i })); + + await waitFor(() => { + expect(screen.getByText(/Event sent\./i)).toBeInTheDocument(); + }); + expect(screen.getByText(/no id/i)).toBeInTheDocument(); + }); + + test('surfaces the error message when the helper throws', async () => { + hoisted.trigger.mockRejectedValue(new Error('network broke')); + vi.resetModules(); + const Panel = await importPanel(); + renderWithProviders(); + + fireEvent.click(screen.getByRole('button', { name: /Send test event/i })); + + await waitFor(() => { + expect(screen.getByText(/Failed: network broke/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts new file mode 100644 index 000000000..3dac3269b --- /dev/null +++ b/app/src/services/__tests__/analytics.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// Hoisted mocks so tests can swap return values per case. +const hoisted = vi.hoisted(() => ({ + getClient: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + flush: vi.fn(() => Promise.resolve(true)), + init: vi.fn(), + // Integration stubs — these aren't introspected, just need to exist so + // `Sentry.init()` accepts the integrations array without throwing. + functionToStringIntegration: vi.fn(() => ({})), + linkedErrorsIntegration: vi.fn(() => ({})), + dedupeIntegration: vi.fn(() => ({})), + browserApiErrorsIntegration: vi.fn(() => ({})), + globalHandlersIntegration: vi.fn(() => ({})), + analyticsEnabled: false, + appEnvironment: 'staging' as 'staging' | 'production' | 'development', +})); + +vi.mock('@sentry/react', () => ({ + getClient: hoisted.getClient, + captureException: hoisted.captureException, + captureMessage: hoisted.captureMessage, + flush: hoisted.flush, + init: hoisted.init, + functionToStringIntegration: hoisted.functionToStringIntegration, + linkedErrorsIntegration: hoisted.linkedErrorsIntegration, + dedupeIntegration: hoisted.dedupeIntegration, + browserApiErrorsIntegration: hoisted.browserApiErrorsIntegration, + globalHandlersIntegration: hoisted.globalHandlersIntegration, +})); + +// `initSentry()` reads `getCoreStateSnapshot().snapshot.analyticsEnabled` to +// decide whether non-test events get dropped. Mock it so each test can flip +// consent without instantiating the real Redux/persistence stack. +vi.mock('../../lib/coreState/store', () => ({ + getCoreStateSnapshot: () => ({ + snapshot: { analyticsEnabled: hoisted.analyticsEnabled, currentUser: null }, + }), +})); + +// `initSentry()` only does anything when SENTRY_DSN is truthy and IS_DEV is +// false. Mock the whole config module so we control both gates. Use a +// getter for APP_ENVIRONMENT so tests can flip staging/production per-case +// to exercise the defense-in-depth gates added for the consent bypass. +vi.mock('../../utils/config', () => ({ + get APP_ENVIRONMENT() { + return hoisted.appEnvironment; + }, + IS_DEV: false, + SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1', + SENTRY_RELEASE: 'openhuman@test+abc', + SENTRY_SMOKE_TEST: false, +})); + +describe('triggerSentryTestEvent', () => { + beforeEach(() => { + hoisted.getClient.mockReset(); + hoisted.captureException.mockReset(); + hoisted.flush.mockReset(); + hoisted.flush.mockReturnValue(Promise.resolve(true)); + hoisted.init.mockReset(); + hoisted.appEnvironment = 'staging'; + }); + + test('refuses to fire outside staging (defense in depth)', async () => { + hoisted.appEnvironment = 'production'; + hoisted.getClient.mockReturnValue({}); + const { triggerSentryTestEvent } = await import('../analytics'); + + const result = await triggerSentryTestEvent(); + + expect(result).toBeUndefined(); + expect(hoisted.captureException).not.toHaveBeenCalled(); + expect(hoisted.flush).not.toHaveBeenCalled(); + }); + + test('returns undefined when Sentry client is not initialized', async () => { + hoisted.getClient.mockReturnValue(undefined); + const { triggerSentryTestEvent } = await import('../analytics'); + + const result = await triggerSentryTestEvent(); + + expect(result).toBeUndefined(); + expect(hoisted.captureException).not.toHaveBeenCalled(); + expect(hoisted.flush).not.toHaveBeenCalled(); + }); + + test('captures a tagged staging-test exception and flushes', async () => { + hoisted.getClient.mockReturnValue({}); + hoisted.captureException.mockReturnValue('event-id-abc'); + hoisted.flush.mockReturnValue(Promise.resolve(true)); + const { triggerSentryTestEvent } = await import('../analytics'); + + const result = await triggerSentryTestEvent(); + + expect(result).toBe('event-id-abc'); + expect(hoisted.captureException).toHaveBeenCalledTimes(1); + + const [thrown, ctx] = hoisted.captureException.mock.calls[0]; + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).name).toBe('SentryStagingTestError'); + // Message is constant so Sentry groups every test click into one issue. + expect((thrown as Error).message).toBe('Manual Sentry test from staging UI'); + expect(ctx).toMatchObject({ + tags: { test: 'manual-staging', source: 'developer-options-button' }, + level: 'error', + }); + // Per-click timing rides on `extra`, not in the message — high cardinality + // there would explode tag indexes and break grouping. + expect((ctx as { extra: { triggered_at: string } }).extra.triggered_at).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ); + expect(hoisted.flush).toHaveBeenCalledWith(2000); + }); + + test('throws when flush times out so the UI surfaces an error', async () => { + hoisted.getClient.mockReturnValue({}); + hoisted.captureException.mockReturnValue('event-id-stuck'); + hoisted.flush.mockReturnValue(Promise.resolve(false)); + const { triggerSentryTestEvent } = await import('../analytics'); + + await expect(triggerSentryTestEvent()).rejects.toThrow(/timed out/i); + }); +}); + +describe('initSentry beforeSend manual-staging bypass', () => { + /** Capture the `beforeSend` callback that `initSentry` registers. */ + async function captureBeforeSend(): Promise< + (event: Record) => Record | null + > { + hoisted.init.mockReset(); + const { initSentry } = await import('../analytics'); + initSentry(); + expect(hoisted.init).toHaveBeenCalledTimes(1); + const opts = hoisted.init.mock.calls[0][0] as { + beforeSend: (event: Record) => Record | null; + }; + return opts.beforeSend.bind(opts); + } + + beforeEach(() => { + hoisted.analyticsEnabled = false; + hoisted.appEnvironment = 'staging'; + }); + + test('drops events when consent is off and event is not test-tagged', async () => { + const beforeSend = await captureBeforeSend(); + const result = beforeSend({ message: 'something blew up', tags: {}, contexts: {} }); + expect(result).toBeNull(); + }); + + test('lets manual-staging tagged events through even without consent', async () => { + const beforeSend = await captureBeforeSend(); + const result = beforeSend({ + message: 'something blew up', + tags: { test: 'manual-staging' }, + breadcrumbs: [{ message: 'should-be-stripped' }], + request: { url: 'https://api.example.com/secret' }, + extra: { token: 'redacted-please' }, + contexts: { os: { name: 'macOS' }, app: { build: '123' } }, + }) as Record | null; + + expect(result).not.toBeNull(); + // PII / breadcrumbs / request body / extras must all be stripped. + expect((result as { breadcrumbs: unknown[] }).breadcrumbs).toEqual([]); + expect(result).not.toHaveProperty('request'); + expect(result).not.toHaveProperty('extra'); + // `app` context is stripped — only os/browser/device kept. + expect((result as { contexts: Record }).contexts).not.toHaveProperty('app'); + expect((result as { contexts: Record }).contexts).toHaveProperty('os'); + // `surface=react` is added so the dashboard can filter cleanly. + expect((result as { tags: Record }).tags).toMatchObject({ + test: 'manual-staging', + surface: 'react', + }); + }); + + test('still lets the smoke-test message through (existing behaviour)', async () => { + const beforeSend = await captureBeforeSend(); + const result = beforeSend({ message: 'react-sentry-smoke-test', tags: {}, contexts: {} }); + expect(result).not.toBeNull(); + }); + + test('drops manual-staging tagged events in production even with the tag', async () => { + // Defense in depth: a stray `tags.test = 'manual-staging'` in production + // must NOT bypass the consent gate. Capture beforeSend in staging, then + // flip APP_ENVIRONMENT to production *before* invoking it, so the + // `isManualTest` check inside beforeSend re-reads the live value via the + // mocked getter. + const beforeSend = await captureBeforeSend(); + hoisted.appEnvironment = 'production'; + const result = beforeSend({ + message: 'pretending to be a test event', + tags: { test: 'manual-staging' }, + contexts: {}, + }); + expect(result).toBeNull(); + }); +}); diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index f09c16bd8..c378e4d08 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -60,8 +60,17 @@ export function initSentry(): void { // Always allow the smoke-test event through so pipeline validation works // even when the user hasn't opted into analytics yet on first boot. const isSmokeTest = event.message === 'react-sentry-smoke-test'; + // Manual staging test events fired from the Developer Options button + // (#1072) bypass the consent gate so QA can validate the pipeline + // without needing to flip user-facing analytics first. The bypass is + // *also* gated on APP_ENVIRONMENT so a stray `manual-staging` tag in + // production (whether accidental or malicious) cannot exfiltrate an + // event past the consent gate — the only legitimate caller in this + // codebase is `triggerSentryTestEvent` and it itself refuses to fire + // outside staging. + const isManualTest = APP_ENVIRONMENT === 'staging' && event.tags?.test === 'manual-staging'; // Drop events when the user hasn't opted into analytics. - if (!isSmokeTest && !isAnalyticsEnabled()) return null; + if (!isSmokeTest && !isManualTest && !isAnalyticsEnabled()) return null; // Strip anything that could carry Redux / localStorage / request bodies. event.breadcrumbs = []; @@ -136,3 +145,59 @@ export function syncAnalyticsConsent(enabled: boolean): void { void Sentry.flush(2000); } } + +/** + * Fire a manual diagnostic event for issue #1072: a staging-only "Trigger + * Sentry Test" button uses this to validate the React → Sentry pipeline + * end-to-end after a config change. Tagged so `beforeSend` lets it through + * regardless of analytics consent, and so it's trivial to filter on the + * dashboard side. Returns the event id Sentry assigns (or `undefined` if + * Sentry is disabled in this build). + */ +export async function triggerSentryTestEvent(): Promise { + // Fail-fast outside staging. The UI button is only rendered when + // `APP_ENVIRONMENT === 'staging'`, but this guard exists as defense in + // depth so a programmatic caller (a stray import, a future refactor) + // cannot fire diagnostic events from production. `beforeSend` already + // re-checks the same gate before applying the consent bypass. + if (APP_ENVIRONMENT !== 'staging') { + console.warn( + `[sentry-test] refusing to fire test event outside staging (APP_ENVIRONMENT=${APP_ENVIRONMENT})` + ); + return undefined; + } + + const client = Sentry.getClient(); + if (!client) { + console.warn('[sentry-test] Sentry client not initialized — DSN missing or dev build'); + return undefined; + } + + // Constant message so Sentry's default grouping algorithm collapses every + // QA click into one issue (with N events) instead of one issue per click. + // Per-click timing goes through `extra` so it's still visible on each + // event but doesn't influence the fingerprint. + const stamp = new Date().toISOString(); + const error = new Error('Manual Sentry test from staging UI'); + error.name = 'SentryStagingTestError'; + + const eventId = Sentry.captureException(error, { + tags: { test: 'manual-staging', source: 'developer-options-button' }, + extra: { triggered_at: stamp }, + level: 'error', + }); + + console.info('[sentry-test] captureException eventId=', eventId); + // Surface flush timeouts as failures: a `false` here means the event + // queue did not drain within 2s, so the network round-trip to Sentry is + // unconfirmed. For a *diagnostic* tool, returning a successful-looking + // eventId in that case would be a lie. + const flushed = await Sentry.flush(2000); + if (!flushed) { + throw new Error( + 'Sentry.flush(2000) timed out — event may not have reached Sentry. ' + + 'Check network / DSN / Sentry status before retrying.' + ); + } + return eventId; +}