import {createContext, Fragment, Ref, useEffect, useRef} from 'react'; import {Client} from 'sentry/api'; import {Organization} from 'sentry/types'; import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {createDefinedContext} from './utils'; type QueryObject = { query: { [k: string]: any; }; }; // TODO(k-fish): Fix to ensure exact types for all requests. Simplified type for now, need to pull this in from events file. type BatchQueryDefinition = { api: Client; batchProperty: string; path: string; reject: (reason?: string) => void; requestQueryObject: QueryObject; // Intermediate promise functions resolve: (value: any) => void; }; type QueryBatch = { addQuery: (q: BatchQueryDefinition, id: symbol) => void; }; const [GenericQueryBatcherProvider, _useGenericQueryBatcher] = createDefinedContext({ name: 'GenericQueryBatcherContext', }); function mergeKey(query: BatchQueryDefinition) { return `${query.batchProperty}.${query.path}`; } type MergeMap = Record; // Builds a map that will contain an array of query definitions by mergeable key (using batch property and path) function queriesToMap(collectedQueries: Record) { const keys = Reflect.ownKeys(collectedQueries); if (!keys.length) { return false; } const mergeMap: MergeMap = {}; keys.forEach(key => { const query = collectedQueries[key]; mergeMap[mergeKey(query)] = mergeMap[mergeKey(query)] || []; mergeMap[mergeKey(query)].push(query); delete collectedQueries[key]; }); return mergeMap; } function requestFunction(api: Client, path: string, queryObject: QueryObject) { return api.requestPromise(path, queryObject); } function _handleUnmergeableQuery(queryDefinition: BatchQueryDefinition) { const result = requestFunction( queryDefinition.api, queryDefinition.path, queryDefinition.requestQueryObject ); queryDefinition.resolve(result); } function _handleUnmergeableQueries(mergeMap: MergeMap) { let queriesSent = 0; Object.keys(mergeMap).forEach(k => { // Using async forEach to ensure calls start in parallel. const mergeList = mergeMap[k]; if (mergeList.length === 1) { const [queryDefinition] = mergeList; queriesSent++; _handleUnmergeableQuery(queryDefinition); } }); return queriesSent; } function _handleMergeableQueries(mergeMap: MergeMap) { let queriesSent = 0; Object.keys(mergeMap).forEach(async k => { const mergeList = mergeMap[k]; if (mergeList.length <= 1) { return; } const [exampleDefinition] = mergeList; const batchProperty = exampleDefinition.batchProperty; const query = {...exampleDefinition.requestQueryObject.query}; const requestQueryObject = {...exampleDefinition.requestQueryObject, query}; const batchValues: string[] = []; mergeList.forEach(q => { const batchFieldValue = q.requestQueryObject.query[batchProperty]; if (Array.isArray(batchFieldValue)) { if (batchFieldValue.length > 1) { // Omit multiple requests with multi fields (eg. yAxis) for now and run them as single queries queriesSent++; _handleUnmergeableQuery(q); return; } // Unwrap array value if it is a single value batchValues.push(batchFieldValue[0]); } else { batchValues.push(batchFieldValue); } }); requestQueryObject.query[batchProperty] = batchValues; queriesSent++; const requestPromise = requestFunction( exampleDefinition.api, exampleDefinition.path, requestQueryObject ); try { const result = await requestPromise; // Unmerge back into individual results mergeList.forEach(queryDefinition => { const propertyName = Array.isArray( queryDefinition.requestQueryObject.query[queryDefinition.batchProperty] ) ? queryDefinition.requestQueryObject.query[queryDefinition.batchProperty][0] : queryDefinition.requestQueryObject.query[queryDefinition.batchProperty]; const singleResult = result[propertyName]; queryDefinition.resolve(singleResult); }); } catch (e) { // On error fail all requests relying on this merged query (for now) mergeList.forEach(q => q.reject(e)); } }); return queriesSent; } function handleBatching( organization: Organization, queries: Record ) { const mergeMap = queriesToMap(queries); if (!mergeMap) { return; } let queriesSent = 0; queriesSent += _handleUnmergeableQueries(mergeMap); queriesSent += _handleMergeableQueries(mergeMap); const queriesCollected = Object.values(mergeMap).reduce( (acc, mergeList) => acc + mergeList.length, 0 ); const queriesSaved = queriesCollected - queriesSent; trackAdvancedAnalyticsEvent('performance_views.landingv3.batch_queries', { organization, num_collected: queriesCollected, num_saved: queriesSaved, num_sent: queriesSent, }); } export const GenericQueryBatcher = ({children}: {children: React.ReactNode}) => { const queries = useRef>({}); const timeoutRef = useRef(undefined); const organization = useOrganization(); const addQuery = (q: BatchQueryDefinition, id: symbol) => { queries.current[id] = q; window.clearTimeout(timeoutRef.current); // Put batch function in the next macro task to aggregate all requests in this frame. timeoutRef.current = window.setTimeout(() => { handleBatching(organization, queries.current); timeoutRef.current = undefined; }, 0); }; // Cleanup timeout after component unmounts. useEffect( () => () => { timeoutRef.current && window.clearTimeout(timeoutRef.current); }, [] ); return ( {children} ); }; type NodeContext = { batchProperty: string; id: Ref; }; const BatchNodeContext = createContext(undefined); export type QueryBatching = { batchRequest: (_: Client, path: string, query: QueryObject) => Promise; }; // Wraps api request components to collect at most one request per frame / render pass using symbol as a unique id. // Transforms these requests into an intermediate promise and adds a query definition that the batch function will use. export function QueryBatchNode(props: { batchProperty: string; children(_: any): React.ReactNode; }) { const api = useApi(); const {batchProperty, children} = props; const id = useRef(Symbol()); let batchContext: QueryBatch; try { batchContext = _useGenericQueryBatcher(); } catch (_) { return {children({})}; } function batchRequest( _: Client, path: string, requestQueryObject: QueryObject ): Promise { const queryPromise = new Promise((resolve, reject) => { const queryDefinition: BatchQueryDefinition = { resolve, reject, batchProperty, path, requestQueryObject, api, }; batchContext?.addQuery(queryDefinition, id.current); }); return queryPromise; } const queryBatching: QueryBatching = { batchRequest, }; return ( {children({queryBatching})} ); }