Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions app/__tests__/safety/crisis-button-telemetry-ordering.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* CRISIS BUTTON — TELEMETRY MUST NEVER GATE THE DIAL (INFRA-297)
*
* The invariant under test: on any crisis-button activation, the navigate-or-dial
* call is the FIRST thing that happens, unconditionally, and no telemetry failure
* can prevent it, delay it, or silence it.
*
* Why this file exists. Before INFRA-297, `handleCrisisAction` called `onNavigate()`
* from INSIDE a `Sentry.startSpan(...)` callback. `startSpan` does real work before
* it ever invokes that callback — async-context-strategy dispatch, a scope fork, a
* sampling decision, span creation (see @sentry/core .../tracing/trace.js). If any
* of it throws, the callback never runs, so **the crisis tap produces nothing at
* all**: no navigation, no dial, and no log — because the audit call lived inside
* the same callback. That is a false negative on a zero-false-negative path.
*
* Wrapping the span in try/catch does NOT fix it. That converts a visible crash
* into a silently swallowed tap, which is worse. Hence the ordering assertions
* below, not merely the occurrence ones: an occurrence-only test would pass a
* try/catch non-fix. `onNavigate` must run BEFORE any telemetry call, not just
* eventually.
*
* Gating: this file is deliberately named and placed to be picked up by BOTH
* `npm run test:safety` (precommit, `__tests__/safety` pattern) and the CI
* `crisis-validation` job (`[Cc]risis` pattern). A test no gate runs is decoration.
*/

import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';

/**
* Shared call-order ledger. Every mocked telemetry entry point and the
* `onNavigate` spy append to it, so ordering — not just occurrence — is
* assertable.
*/
const callOrder: string[] = [];

const mockStartSpan = jest.fn();

jest.mock('@sentry/react-native', () => ({
get startSpan() {
return mockStartSpan;
},
}));

jest.mock('@/core/services/logging', () => ({
logSecurity: jest.fn(() => {
callOrder.push('logSecurity');
}),
logPerformance: jest.fn(() => {
callOrder.push('logPerformance');
}),
logCrisis: jest.fn(() => {
callOrder.push('logCrisis');
}),
}));

import CollapsibleCrisisButton from '@/features/crisis/components/CollapsibleCrisisButton';

const TEST_ID = 'crisis-button-prominent';

/** Fresh spy that records into the shared ledger. */
function makeNavigateSpy(): jest.Mock {
return jest.fn(() => {
callOrder.push('onNavigate');
});
}

function pressCrisisButton(onNavigate: jest.Mock): void {
const { getByTestId } = render(
<CollapsibleCrisisButton onNavigate={onNavigate} mode="prominent" testID={TEST_ID} />,
);
fireEvent.press(getByTestId(TEST_ID));
}

beforeEach(() => {
callOrder.length = 0;
jest.clearAllMocks();
// Default: a well-behaved span that records its own position in the ledger.
mockStartSpan.mockImplementation((_options: unknown, callback?: (span: unknown) => void) => {
callOrder.push('startSpan');
return callback?.({
setAttribute: () => {
callOrder.push('span.setAttribute');
},
});
});
});

describe('INFRA-297 — the dial survives any telemetry failure', () => {
test('startSpan THROWS → onNavigate still fires exactly once', () => {
mockStartSpan.mockImplementation(() => {
callOrder.push('startSpan');
throw new Error('sentry exploded before invoking the callback');
});

const onNavigate = makeNavigateSpy();
expect(() => pressCrisisButton(onNavigate)).not.toThrow();

expect(onNavigate).toHaveBeenCalledTimes(1);
});

test('startSpan returns WITHOUT invoking its callback → onNavigate still fires', () => {
// The realistic shape of a sampling/scope failure: no throw, just a no-op.
// This is the case a try/catch cannot possibly rescue.
mockStartSpan.mockImplementation(() => {
callOrder.push('startSpan');
return undefined;
});

const onNavigate = makeNavigateSpy();
pressCrisisButton(onNavigate);

expect(onNavigate).toHaveBeenCalledTimes(1);
});

test('span.setAttribute THROWS → onNavigate still fires and nothing escapes', () => {
mockStartSpan.mockImplementation((_o: unknown, callback?: (span: unknown) => void) => {
callOrder.push('startSpan');
return callback?.({
setAttribute: () => {
throw new Error('setAttribute exploded');
},
});
});

const onNavigate = makeNavigateSpy();
expect(() => pressCrisisButton(onNavigate)).not.toThrow();
expect(onNavigate).toHaveBeenCalledTimes(1);
});

test('span is undefined → onNavigate still fires and nothing escapes', () => {
mockStartSpan.mockImplementation((_o: unknown, callback?: (span: unknown) => void) => {
callOrder.push('startSpan');
return callback?.(undefined);
});

const onNavigate = makeNavigateSpy();
expect(() => pressCrisisButton(onNavigate)).not.toThrow();
expect(onNavigate).toHaveBeenCalledTimes(1);
});

test('Sentry module entirely absent (startSpan undefined) → onNavigate still fires', () => {
mockStartSpan.mockImplementation(() => {
throw new TypeError('Sentry.startSpan is not a function');
});

const onNavigate = makeNavigateSpy();
expect(() => pressCrisisButton(onNavigate)).not.toThrow();
expect(onNavigate).toHaveBeenCalledTimes(1);
});
});

describe('INFRA-297 — ordering, not merely occurrence', () => {
test('onNavigate runs BEFORE any telemetry call', () => {
const onNavigate = makeNavigateSpy();
pressCrisisButton(onNavigate);

expect(callOrder).toContain('onNavigate');
const navIdx = callOrder.indexOf('onNavigate');

// Every telemetry entry that fired must come strictly after the navigate.
// This is what a try/catch-around-the-span "fix" would fail.
const telemetry = ['startSpan', 'span.setAttribute', 'logSecurity', 'logPerformance'];
for (const entry of telemetry) {
const idx = callOrder.indexOf(entry);
if (idx === -1) continue;
expect(idx).toBeGreaterThan(navIdx);
}
});

test('onNavigate is the very first recorded call on the tap path', () => {
const onNavigate = makeNavigateSpy();
pressCrisisButton(onNavigate);

expect(callOrder[0]).toBe('onNavigate');
});

test('a throwing telemetry path does not reorder or duplicate the navigate', () => {
mockStartSpan.mockImplementation(() => {
callOrder.push('startSpan');
throw new Error('boom');
});

const onNavigate = makeNavigateSpy();
pressCrisisButton(onNavigate);

expect(callOrder[0]).toBe('onNavigate');
expect(onNavigate).toHaveBeenCalledTimes(1);
});
});
70 changes: 43 additions & 27 deletions app/src/features/crisis/components/CollapsibleCrisisButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ import Animated, {
interpolate,
} from 'react-native-reanimated';
import { MaterialDesignIcons } from '@react-native-vector-icons/material-design-icons';
import * as Sentry from '@sentry/react-native';
import { logSecurity, logPerformance, logCrisis } from '@/core/services/logging';
import { logCrisis } from '@/core/services/logging';
// Eager import, deliberately — the crisis path never lazy-imports (CLAUDE.md).
// Sentry itself is no longer imported here: all crisis-tap telemetry moved into
// crisisTapTrace so that no telemetry code can sit upstream of the dial.
import { beginCrisisTap } from '@/features/crisis/services/crisisTapTrace';
import { spacing, borderRadius, typography, colorSystem } from '@/core/theme';

/** Display mode for the crisis button */
Expand Down Expand Up @@ -184,31 +187,44 @@ export const CollapsibleCrisisButton: React.FC<CollapsibleCrisisButtonProps> = (
* Direct tap works immediately, even in faded state
*/
const handleCrisisAction = useCallback(() => {
Sentry.startSpan(
{ name: 'crisis_button_response', op: 'ui.crisis.tap' },
(span) => {
const startTime = performance.now();

// Reset fade on interaction
resetFade();

// Navigate to CrisisResourcesScreen (provides choice: Call 988, Text 741741, Emergency contacts)
onNavigate();

// Performance monitoring for clinical safety
const responseTime = performance.now() - startTime;
span?.setAttribute('response_time_ms', responseTime);
span?.setAttribute('exceeded_budget', responseTime > 200);
if (responseTime > 200) {
logSecurity('Crisis button response time exceeded', 'high', {
responseTime,
threshold: 200
});
} else {
logPerformance('crisis_button_response', responseTime);
}
}
);
// ORDERING IS THE SAFETY CONTRACT HERE (INFRA-297). Do not reorder.
//
// `onNavigate()` used to be called from INSIDE a `Sentry.startSpan` callback.
// `startSpan` does real work before it invokes that callback — async-context
// strategy dispatch, a scope fork, a sampling decision, span creation. If any
// of it throws, the callback never runs and the crisis tap produces NOTHING:
// no navigation, no dial, and no log, because the audit call was inside the
// same callback. On the CrisisErrorBoundary path that is the last-resort
// tel:988 dial in an already-crashed app — the one context where Sentry is
// itself a likely cause of the crash.
//
// A try/catch around the span does NOT fix that: it turns a visible crash
// into a silently swallowed tap, which is worse. So the navigate is moved
// out of the callback entirely, and telemetry is strictly downstream.
// Pinned by __tests__/safety/crisis-button-telemetry-ordering.test.tsx,
// which asserts ORDER, not just occurrence — occurrence alone would pass a
// try/catch non-fix.

// Opens the tap→render measurement. Must precede the navigate (you cannot
// measure tap→render starting after the navigate). Safe to sit here only
// because it reads a clock, writes one field, schedules a timer, and is
// internally guarded so it cannot throw. Do not add anything to it.
beginCrisisTap('crisis_button');

// THE CRISIS ACTION. First, unconditional, synchronous, outside every
// telemetry construct. Navigates to CrisisResourcesScreen (choice of Call
// 988, Text 741741, emergency contacts) — or, on the error-boundary mount,
// dials 988 directly.
onNavigate();

// Cosmetic only, and therefore after. Nothing may run ahead of the crisis
// action for the sake of a fade animation.
resetFade();

// No telemetry here by design. The measurement closes at the point the user
// can actually act — CrisisResourcesScreen's commit, or the OS taking the
// dial — which also moves the span and log work off this tap frame entirely.
// Net effect: this path is now faster than before, not slower.
}, [onNavigate, resetFade]);

/**
Expand Down
17 changes: 16 additions & 1 deletion app/src/features/crisis/components/CrisisErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,23 @@ export class CrisisErrorBoundary extends Component<
{/* Crisis support - always first priority */}
<View style={styles.actionGroup}>
<Text style={styles.actionGroupTitle}>🚨 Crisis Support (24/7)</Text>
{/*
CORRECTED (INFRA-297). This previously read "Use the crisis button
at the top of your screen for immediate support," which was false
twice over in this branch:
1. No crisis button was on screen. This boundary replaced its
children, and RootCrisisButton suppresses the root overlay on
'AssessmentFlow' — the only route this boundary wraps. So the
copy pointed at a control that did not exist.
2. The button is bottom-right, not "at the top".
RootCrisisButton's own header claims this boundary "keeps its OWN
CollapsibleCrisisButton … the last-resort 988 access when a render
crash removes this root-mounted overlay". That claim was only true
of the fallbackComponent branch, which no caller uses. The button
below makes it true for the branch that actually renders.
*/}
<Text style={styles.crisisNotice}>
Use the crisis button at the top of your screen for immediate support.
Tap the support button below to call or text a crisis line now.
</Text>
<Pressable
style={styles.emergencyButton}
Expand Down
20 changes: 19 additions & 1 deletion app/src/features/crisis/screens/CrisisResourcesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* - Accessibility: WCAG AA compliant
*/

import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useLayoutEffect, useCallback } from 'react';
import {
View,
Text,
Expand All @@ -33,6 +33,7 @@ import { useAnalytics } from '@/core/analytics';
import { colorSystem, spacing, borderRadius, typography } from '@/core/theme';
import { logPerformance, logSecurity, logError, LogCategory } from '@/core/services/logging';
import { openCrisisUrl } from '@/features/crisis/utils/openCrisisUrl';
import { endCrisisTap } from '@/features/crisis/services/crisisTapTrace';
import {
CRISIS_RESOURCE_CATEGORIES,
getPriorityCrisisResources,
Expand Down Expand Up @@ -239,6 +240,23 @@ export default function CrisisResourcesScreen() {
}, [trackScreenView, trackCrisisResourcesViewed])
);

// Close the crisis-tap measurement opened by the crisis button (INFRA-297).
//
// `useLayoutEffect`, deliberately: it fires synchronously after React commits
// the tree, so the view hierarchy exists — the closest defensible boundary for
// "the user can see it". Rejected alternatives, do not "improve" this later:
// - render body → fires before commit, and on every re-render.
// - InteractionManager.runAfterInteractions → waits for the modal transition
// animation to drain, folding hundreds of ms of settle into the number and
// putting the p95 permanently over budget for reasons unrelated to
// responsiveness.
// - onLayout → per-view, refires on re-layout, ordering not guaranteed.
// A no-op when no mark is open (e.g. the screen was reached by a route other
// than the crisis button), by design.
useLayoutEffect(() => {
endCrisisTap('screen_commit');
}, []);

// Track screen load performance
useEffect(() => {
const loadTime = performance.now() - startTime;
Expand Down
Loading
Loading