Skip to content
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2893,6 +2893,8 @@ const CONST = {
ATTRIBUTE_UPDATE_ID_TO: 'update_id_to',
ATTRIBUTE_RESPONSE_ADVANCED: 'response_advanced',
ATTRIBUTE_COLD_START: 'cold_start',
// Whether a Hermes profile was requested to run or stop during this span, so profiler overhead can be split out.
ATTRIBUTE_PROFILED: 'profiled',
ATTRIBUTE_TRIGGER: 'trigger',
ATTRIBUTE_PLATFORM: 'platform',
ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan',
Expand Down
23 changes: 19 additions & 4 deletions src/components/Search/SearchRouter/SearchRouterHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ function getGoToText(translate: LocaleContextProps['translate'], destination: st
return translate('search.goTo', {destination});
}

/** Whether the query can produce navigation suggestions at all, so callers can skip building the sources when it cannot. */
function canQueryMatchNavigationSuggestions(query: string) {
const trimmedQuery = query.trim();
return !!stripNavigationIntentPrefix(trimmedQuery) || isNavigationIntentOnlyQuery(trimmedQuery);
}

function buildNavigationSuggestions(query: string, sources: NavigationSuggestionSourceItem[][], localeCompare: LocaleContextProps['localeCompare']): SearchQueryItem[] {
if (!canQueryMatchNavigationSuggestions(query)) {
return [];
}
const trimmedQuery = query.trim();
const isNavigationIntentOnly = isNavigationIntentOnlyQuery(trimmedQuery);
const matchQuery = stripNavigationIntentPrefix(trimmedQuery);
if (!matchQuery && !isNavigationIntentOnly) {
return [];
}

const shouldMatchExactDestination = matchQuery.length < MIN_NAVIGATION_QUERY_LENGTH && !isNavigationIntentOnly;

Expand All @@ -98,5 +104,14 @@ function buildNavigationSuggestions(query: string, sources: NavigationSuggestion
.slice(0, MAX_NAVIGATION_SUGGESTIONS);
}

export {stripNavigationIntentPrefix, isNavigationIntentOnlyQuery, matchesNavigationQuery, sortNavigationSuggestionItems, getGoToText, buildNavigationSuggestions, MAX_NAVIGATION_SUGGESTIONS};
export {
canQueryMatchNavigationSuggestions,
stripNavigationIntentPrefix,
isNavigationIntentOnlyQuery,
matchesNavigationQuery,
sortNavigationSuggestionItems,
getGoToText,
buildNavigationSuggestions,
MAX_NAVIGATION_SUGGESTIONS,
};
export type {NavigationSuggestionSourceItem};
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {View} from 'react-native';

import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers';

import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers';
import {buildNavigationSuggestions, canQueryMatchNavigationSuggestions, getGoToText} from './SearchRouterHelpers';
import useCreateNavigationSuggestions from './useCreateNavigationSuggestions';

type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'PieChart' | 'Building' | 'Globe' | 'Gear', IconAsset>;
Expand Down Expand Up @@ -420,9 +420,16 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true)
const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector});
const {clearSelectedTransactions} = useSearchSelectionActions();
const typeMenuSections = useSearchTypeMenuSectionsForNavigation(shouldWatchForApprovals);
const canQueryMatch = canQueryMatchNavigationSuggestions(query);
const typeMenuSections = useSearchTypeMenuSectionsForNavigation(shouldWatchForApprovals, canQueryMatch);
const {accountMenuItemsData, generalMenuItemsData} = useSettingsNavigationMenuData();

// Every Search Router open starts with an empty query, which matches nothing, so the sources (the Spend sections and
// the settings menu of every workspace) are not built on its first frame.
if (!canQueryMatch) {
return [];
}

const topLevelItems = buildTopLevelNavigationItems({
labels: {
home: translate('common.home'),
Expand Down
44 changes: 26 additions & 18 deletions src/hooks/useSearchTypeMenuSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,19 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry<Session>) => ({
accountID: session?.accountID,
});

const NO_TYPE_MENU_SECTIONS: ReturnType<typeof createTypeMenuSections> = [];

/**
* Get a list of all search groupings, along with their search items.
*
* `isScreenFocused` gates the reports-awaiting-approval watch so an off-screen consumer stops recomputing it. It
* defaults to `true` (always watch) for consumers rendered outside a navigator or where focus can't be tracked
* reliably, so this hook never depends on a navigation context itself.
*
* `shouldBuildSections` set to false skips building the sections (canned queries are parsed per item) and returns an
* empty list, for consumers that only show them conditionally.
*/
const useSearchTypeMenuSections = (isScreenFocused = true) => {
const useSearchTypeMenuSections = (isScreenFocused = true, shouldBuildSections = true) => {
const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector});

const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay();
Expand Down Expand Up @@ -109,22 +114,25 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => {

const typeMenuSections = useMemo(
() =>
createTypeMenuSections({
currentUserEmail: currentUserLoginAndAccountID?.email,
currentUserAccountID: currentUserLoginAndAccountID?.accountID,
cardFeedsByPolicy,
defaultCardFeed: defaultCardFeed ?? defaultExpensifyCard,
policies: allPolicies,
savedSearches,
isOffline,
defaultExpensifyCard,
activeExpensifyCardFeedID,
draftTransactionIDs,
isTrackIntentUser: isTrackIntentUser ?? false,
hasReportAwaitingApproval,
policyCategories: allPolicyCategories,
}),
!shouldBuildSections
? NO_TYPE_MENU_SECTIONS
: createTypeMenuSections({
currentUserEmail: currentUserLoginAndAccountID?.email,
currentUserAccountID: currentUserLoginAndAccountID?.accountID,
cardFeedsByPolicy,
defaultCardFeed: defaultCardFeed ?? defaultExpensifyCard,
policies: allPolicies,
savedSearches,
isOffline,
defaultExpensifyCard,
activeExpensifyCardFeedID,
draftTransactionIDs,
isTrackIntentUser: isTrackIntentUser ?? false,
hasReportAwaitingApproval,
policyCategories: allPolicyCategories,
}),
[
shouldBuildSections,
currentUserLoginAndAccountID?.email,
currentUserLoginAndAccountID?.accountID,
cardFeedsByPolicy,
Expand All @@ -144,8 +152,8 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => {
return typeMenuSections;
};

const useSearchTypeMenuSectionsForNavigation = (isScreenFocused = true) => {
const typeMenuSections = useSearchTypeMenuSections(isScreenFocused);
const useSearchTypeMenuSectionsForNavigation = (isScreenFocused = true, shouldBuildSections = true) => {
const typeMenuSections = useSearchTypeMenuSections(isScreenFocused, shouldBuildSections);
const {isBetaEnabled} = usePermissions();

let sections = typeMenuSections;
Expand Down
8 changes: 8 additions & 0 deletions src/libs/telemetry/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import * as Sentry from '@sentry/react-native';

import {breadcrumbsIntegration, browserProfilingIntegration, consoleIntegration, navigationIntegration, shouldCreateSpanForRequest} from './common';
import searchRouterProfilingSampler from './searchRouterProfilingSampler';

const tracingIntegration = Sentry.reactNativeTracingIntegration({
shouldCreateSpanForRequest,
});

// Only the Search Router open is profiled, see `searchRouterProfilingSampler`. Only the JS profiler runs: the platform
// profilers would add their own cost to the measured spans on top of Hermes sampling.
const searchRouterProfilingSamplerIntegration = searchRouterProfilingSampler();
const hermesProfilingIntegration = Sentry.hermesProfilingIntegration({platformProfilers: false});

// The Reporting API is a browser-only feature, so there is no native integration. This stub keeps
// the web/native export shape in parity; it is filtered out of the integrations list on native.
const reportingObserverIntegration = undefined;
Expand All @@ -25,6 +31,8 @@ export {
navigationIntegration,
tracingIntegration,
browserProfilingIntegration,
searchRouterProfilingSamplerIntegration,
hermesProfilingIntegration,
breadcrumbsIntegration,
consoleIntegration,
reportingObserverIntegration,
Expand Down
6 changes: 6 additions & 0 deletions src/libs/telemetry/integrations/index.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,16 @@ const thirdPartyErrorFilterIntegration = isApplicationKeyStamped()
})
: undefined;

// Native-only: they drive the Hermes sampling profiler. Stubs for export shape parity.
const searchRouterProfilingSamplerIntegration = undefined;
const hermesProfilingIntegration = undefined;

export {
navigationIntegration,
tracingIntegration,
browserProfilingIntegration,
searchRouterProfilingSamplerIntegration,
hermesProfilingIntegration,
breadcrumbsIntegration,
consoleIntegration,
reportingObserverIntegration,
Expand Down
93 changes: 93 additions & 0 deletions src/libs/telemetry/integrations/searchRouterProfilingSampler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {getSpan} from '@libs/telemetry/activeSpans';

import CONST from '@src/CONST';

import type {Client, ClientOptions, Integration, Span} from '@sentry/core';
import type {ReactNativeOptions} from '@sentry/react-native';

import {getRootSpan, spanToJSON} from '@sentry/core';
import {Platform} from 'react-native';

/**
* Share of Search Router opens that get a Hermes profile. Android carries the metric's tail on a few hundred opens a
* week, so it gets the higher rate; both stay low because profiled opens pay the profiler's cost inside the measured span.
*/
const SEARCH_ROUTER_PROFILE_SAMPLE_RATE = Platform.OS === 'android' ? 0.2 : 0.05;

const NAVIGATION_SPAN_OP = 'navigation';

// Mirrors `MAX_PROFILE_DURATION_MS` in `@sentry/react-native/dist/js/profiling/constants`, which is not exported. Past it
// the SDK has already stopped the profile on its own, so the next root span pays nothing.
const MAX_PROFILE_DURATION_MS = 30_000;

// Core's client type omits the RN-only profiler rate that `hermesProfilingIntegration` reads back from the live options.
type ProfilingClientOptions = ClientOptions & Pick<ReactNativeOptions, 'profilesSampleRate'>;

/**
* `hermesProfilingIntegration` rolls `profilesSampleRate` on every root span start and stops the running profile when
* the next root span starts. Every `Manual*` span is a root, so a global rate would cut profiles within milliseconds,
* run the synchronous stop (dump, file read, JSON parse) inside the next measured span, and flood the profile quota.
*
* The rate is therefore 1 only for the navigation span dispatched while `ManualOpenSearchRouter` is open: it is the
* last root the open starts. `profiled` goes on the open and page-visible spans, which are separate roots that run while
* the profiler samples, so their profiler overhead can be split out. A root that starts while that profile still runs
* (e.g. `ManualSearchRouterQuery` on fast typing) pays the stop, so it gets `profiled: true` too. The attribute records
* that a profile was requested; whether the native profiler actually started is not visible here.
*
* Must be registered before `hermesProfilingIntegration`, whose `spanStart` listener reads the rate. Setting
* `profilesSampleRate` in `Sentry.init` would break that: the SDK would add its own profiler ahead of this sampler.
*/
function searchRouterProfilingSampler(): Integration {
const sampledOpenSpans = new WeakSet<Span>();
let profiledRootSpan: Span | undefined;
let profileRequestedAt = 0;

return {
name: 'SearchRouterProfilingSampler',
setup(client: Client) {
client.on('spanStart', (span) => {
if (getRootSpan(span) !== span) {
return;
}

if (profiledRootSpan) {
if (Date.now() - profileRequestedAt < MAX_PROFILE_DURATION_MS) {
span.setAttribute(CONST.TELEMETRY.ATTRIBUTE_PROFILED, true);
}
profiledRootSpan = undefined;
}

const options = client.getOptions() as ProfilingClientOptions;
options.profilesSampleRate = 0;

if (spanToJSON(span).op !== NAVIGATION_SPAN_OP) {
return;
}

const openSpan = getSpan(CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER);
if (!openSpan || sampledOpenSpans.has(openSpan)) {
return;
}
sampledOpenSpans.add(openSpan);

const isProfiled = Math.random() < SEARCH_ROUTER_PROFILE_SAMPLE_RATE;
openSpan.setAttribute(CONST.TELEMETRY.ATTRIBUTE_PROFILED, isProfiled);
getSpan(CONST.TELEMETRY.SPAN_SEARCH_PAGE_VISIBLE)?.setAttribute(CONST.TELEMETRY.ATTRIBUTE_PROFILED, isProfiled);
if (isProfiled) {
options.profilesSampleRate = 1;
profiledRootSpan = span;
profileRequestedAt = Date.now();
}
});

client.on('spanEnd', (span) => {
if (span !== profiledRootSpan) {
return;
}
profiledRootSpan = undefined;
});
},
};
}

export default searchRouterProfilingSampler;
8 changes: 6 additions & 2 deletions src/selectors/ReportMetaData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ const pendingChatMembersSelector = (reportMetadata: OnyxEntry<ReportMetadata>):

const pendingDeleteMemberAccountIDsSelector = (reportMetadata: OnyxEntry<ReportMetadata>) => getPendingDeleteMemberAccountIDs(reportMetadata?.pendingChatMembers);

const pendingDeleteMemberAccountIDsByReportIDSelector = (reportMetadata: OnyxCollection<ReportMetadata>): Record<string, string[]> => {
// Shared by every subscriber when nothing is pending, so a fresh subscription returns the same reference. The option
// list cache in OptionsListUtils compares this by reference, and a new empty object per mount made it miss every time.
const NO_PENDING_DELETE_MEMBER_ACCOUNT_IDS: Readonly<Record<string, string[]>> = Object.freeze({});

const pendingDeleteMemberAccountIDsByReportIDSelector = (reportMetadata: OnyxCollection<ReportMetadata>): Readonly<Record<string, string[]>> => {
const result: Record<string, string[]> = {};

for (const [key, metadata] of Object.entries(reportMetadata ?? {})) {
Expand All @@ -50,7 +54,7 @@ const pendingDeleteMemberAccountIDsByReportIDSelector = (reportMetadata: OnyxCol
result[key.replace(ONYXKEYS.COLLECTION.REPORT_METADATA, '')] = pendingDeleteMemberAccountIDs;
}

return result;
return Object.keys(result).length > 0 ? result : NO_PENDING_DELETE_MEMBER_ACCOUNT_IDS;
};

const pendingExpenseActionSelector = (reportMetadata: OnyxEntry<ReportMetadata>): OnyxEntry<ReportMetadata> =>
Expand Down
10 changes: 7 additions & 3 deletions src/setup/telemetry/setupSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
classCallCheckNoiseFilterIntegration,
consoleIntegration,
googleTranslateRecursionNoiseFilterIntegration,
hermesProfilingIntegration,
navigationIntegration,
reportingObserverIntegration,
searchRouterProfilingSamplerIntegration,
thirdPartyErrorFilterIntegration,
tracingIntegration,
} from '@libs/telemetry/integrations';
Expand Down Expand Up @@ -41,6 +43,8 @@ function setupSentry(): void {
navigationIntegration,
tracingIntegration,
browserProfilingIntegration,
searchRouterProfilingSamplerIntegration,
hermesProfilingIntegration,
breadcrumbsIntegration,
consoleIntegration,
reportingObserverIntegration,
Expand Down Expand Up @@ -94,13 +98,13 @@ function setupSentry(): void {
// Native SDK is initialized early in Application.onCreate (Android) and AppDelegate (iOS)
// via SentryNativeSDKManager so native code can report to Sentry before JS loads.
autoInitializeNativeSdk: false,
// We set experimental lifecycle value to enable profiling for whole spans. Without this option profile often is dropped early and we haven't the whole picture
// See https://github.com/Expensify/App/issues/87489
// UI profiling of native threads, see https://github.com/Expensify/App/issues/87489. It reaches the native SDK only
// when RN initializes it, which `autoInitializeNativeSdk: false` prevents, so it has no effect while that is off.
// eslint-disable-next-line @typescript-eslint/naming-convention
_experiments: {
profilingOptions: {
// When updating the profile sample rate, make sure it will not blow up our current limit in Sentry.
// This option replaces `profilesSampleRate`
// Hermes (JS) profiling is separate and gated by `searchRouterProfilingSampler`.
profileSessionSampleRate: 0.5,
lifecycle: 'trace',
},
Expand Down
Loading
Loading