feat(sentry): staging-only "Trigger Sentry Test" button (#1072) (#1183)

This commit is contained in:
CodeGhost21
2026-05-04 10:56:00 -07:00
committed by GitHub
parent 99a4ab1c1c
commit 00bb53de3a
4 changed files with 442 additions and 1 deletions
@@ -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<SentryTestStatus>({ 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 (
<div className="px-4 py-3 mb-3 rounded-lg border border-amber-300 bg-amber-50">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-900">Trigger Sentry Test (staging)</div>
<div className="text-xs text-amber-800 mt-0.5">
Fires a tagged error to verify the Sentry pipeline. Issue #1072 remove after
verification.
</div>
</div>
<button
onClick={onClick}
disabled={status.kind === 'sending'}
className="shrink-0 px-3 py-1.5 rounded-md bg-amber-600 hover:bg-amber-500 text-white text-xs font-medium transition-colors disabled:opacity-60">
{status.kind === 'sending' ? 'Sending…' : 'Send test event'}
</button>
</div>
{/*
* 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.
*/}
<div role="status" aria-live="polite" aria-atomic="true" className="mt-2 text-xs">
{status.kind === 'sent' && (
<span className="text-amber-900">
Event sent.{' '}
{status.eventId ? (
<span className="font-mono">id: {status.eventId}</span>
) : (
<span>(no id Sentry disabled in this build)</span>
)}
</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600">Failed: {status.message}</span>
)}
</div>
</div>
);
};
const DeveloperOptionsPanel = () => {
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
const showSentryTest = APP_ENVIRONMENT === 'staging';
return (
<div className="z-10 relative">
@@ -147,6 +215,7 @@ const DeveloperOptionsPanel = () => {
/>
<div>
{showSentryTest && <SentryTestRow />}
{developerItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
@@ -0,0 +1,106 @@
/**
* Tests for the staging-only "Trigger Sentry Test" row that
* `DeveloperOptionsPanel` renders at the top when
* `APP_ENVIRONMENT === 'staging'`. Covers visibility gating, the
* idle/sending/sent/error state machine, and the failure path.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
const hoisted = vi.hoisted(() => ({
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(<Panel />);
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(<Panel />);
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(<Panel />);
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(<Panel />);
fireEvent.click(screen.getByRole('button', { name: /Send test event/i }));
await waitFor(() => {
expect(screen.getByText(/Failed: network broke/i)).toBeInTheDocument();
});
});
});
@@ -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<string, unknown>) => Record<string, unknown> | 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<string, unknown>) => Record<string, unknown> | 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<string, unknown> | 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<string, unknown> }).contexts).not.toHaveProperty('app');
expect((result as { contexts: Record<string, unknown> }).contexts).toHaveProperty('os');
// `surface=react` is added so the dashboard can filter cleanly.
expect((result as { tags: Record<string, string> }).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();
});
});
+66 -1
View File
@@ -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<string | undefined> {
// 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;
}