Files
2024-02-15 17:01:50 +00:00

676 lines
18 KiB
TypeScript

import pick from 'lodash/pick';
import {createStore} from 'reflux';
import {mergeGroups} from 'sentry/actionCreators/group';
import {
addErrorMessage,
addLoadingMessage,
addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import {Client} from 'sentry/api';
import type {Group, Organization, Project} from 'sentry/types';
import type {Event} from 'sentry/types/event';
import toArray from 'sentry/utils/toArray';
import type {CommonStoreDefinition} from './types';
// Between 0-100
const MIN_SCORE = 0.6;
// @param score: {[key: string]: number}
const checkBelowThreshold = (scores = {}) => {
const scoreKeys = Object.keys(scores);
return !scoreKeys.map(key => scores[key]).find(score => score >= MIN_SCORE);
};
type State = {
// "Compare" button state
enableFingerprintCompare: boolean;
error: boolean;
filteredSimilarItems: SimilarItem[];
loading: boolean;
mergeDisabled: boolean;
mergeList: Array<string>;
mergeState: Map<any, any>;
// List of fingerprints that belong to issue
mergedItems: Fingerprint[];
mergedLinks: string;
similarItems: SimilarItem[];
similarLinks: string;
// Disabled state of "Unmerge" button in "Merged" tab (for Issues)
unmergeDisabled: boolean;
// If "Collapse All" was just used, this will be true
unmergeLastCollapsed: boolean;
// Map of {[fingerprint]: Array<fingerprint, event id>} that is selected to be unmerged
unmergeList: Map<any, any>;
// Map of state for each fingerprint (i.e. "collapsed")
unmergeState: Map<any, any>;
};
type ScoreMap = Record<string, number | null | string>;
type ApiFingerprint = {
id: string;
latestEvent: Event;
childId?: string;
childLabel?: string;
eventCount?: number;
label?: string;
lastSeen?: string;
parentId?: string;
parentLabel?: string;
state?: string;
};
type ChildFingerprint = {
childId: string;
childLabel?: string;
eventCount?: number;
lastSeen?: string;
latestEvent?: Event;
};
export type Fingerprint = {
children: Array<ChildFingerprint>;
eventCount: number;
id: string;
latestEvent: Event;
label?: string;
lastSeen?: string;
parentId?: string;
parentLabel?: string;
state?: string;
};
export type SimilarItem = {
isBelowThreshold: boolean;
issue: Group;
aggregate?: {
exception: number;
message: number;
shouldBeGrouped?: string;
};
score?: Record<string, number | null>;
scoresByInterface?: {
exception: Array<[string, number | null]>;
message: Array<[string, any | null]>;
shouldBeGrouped?: Array<[string, string | null]>;
};
};
type ResponseProcessors = {
merged: (item: ApiFingerprint[]) => Fingerprint[];
similar: (data: [Group, ScoreMap]) => {
aggregate: Record<string, number | string>;
isBelowThreshold: boolean;
issue: Group;
score: ScoreMap;
scoresByInterface: Record<string, Array<[string, number | null]>>;
};
};
type DataKey = keyof ResponseProcessors;
type ResultsAsArrayDataMerged = Parameters<ResponseProcessors['merged']>[0];
type ResultsAsArrayDataSimilar = Array<Parameters<ResponseProcessors['similar']>[0]>;
type ResultsAsArray = Array<{
data: ResultsAsArrayDataMerged | ResultsAsArrayDataSimilar;
dataKey: DataKey;
links: string | null;
}>;
type IdState = {
busy?: boolean;
checked?: boolean;
collapsed?: boolean;
};
type InternalDefinition = {
api: Client;
};
type UnmergeResponse = Pick<
State,
| 'unmergeDisabled'
| 'unmergeState'
| 'unmergeList'
| 'enableFingerprintCompare'
| 'unmergeLastCollapsed'
>;
interface GroupingStoreDefinition
extends CommonStoreDefinition<State>,
InternalDefinition {
getInitialState(): State;
init(): void;
isAllUnmergedSelected(): boolean;
onFetch(
toFetchArray?: Array<{
dataKey: DataKey;
endpoint: string;
queryParams?: Record<string, any>;
}>
): Promise<any>;
onMerge(props: {
projectId: Project['id'];
params?: {
groupId: Group['id'];
orgId: Organization['id'];
};
query?: string;
}): undefined | Promise<any>;
onToggleCollapseFingerprint(fingerprint: string): void;
onToggleCollapseFingerprints(): void;
onToggleMerge(id: string): void;
onToggleUnmerge(props: [string, string] | string): void;
onUnmerge(props: {
groupId: Group['id'];
orgSlug: Organization['slug'];
errorMessage?: string;
loadingMessage?: string;
successMessage?: string;
}): Promise<UnmergeResponse>;
setStateForId(
map: Map<string, IdState>,
idOrIds: Array<string> | string,
newState: IdState
): Array<IdState>;
triggerFetchState(): Pick<
State,
| 'similarItems'
| 'filteredSimilarItems'
| 'mergedItems'
| 'mergedLinks'
| 'similarLinks'
| 'mergeState'
| 'unmergeState'
| 'loading'
| 'error'
>;
triggerMergeState(): Pick<State, 'mergeState' | 'mergeDisabled' | 'mergeList'>;
triggerUnmergeState(): UnmergeResponse;
}
const storeConfig: GroupingStoreDefinition = {
api: new Client(),
init() {
// XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux
// listeners due to their leaky nature in tests.
const state = this.getInitialState();
Object.entries(state).forEach(([key, value]) => {
this[key] = value;
});
},
getInitialState() {
return {
// List of fingerprints that belong to issue
mergedItems: [],
// Map of {[fingerprint]: Array<fingerprint, event id>} that is selected to be unmerged
unmergeList: new Map(),
// Map of state for each fingerprint (i.e. "collapsed")
unmergeState: new Map(),
// Disabled state of "Unmerge" button in "Merged" tab (for Issues)
unmergeDisabled: true,
// If "Collapse All" was just used, this will be true
unmergeLastCollapsed: false,
// "Compare" button state
enableFingerprintCompare: false,
similarItems: [],
filteredSimilarItems: [],
similarLinks: '',
mergeState: new Map(),
mergeList: [],
mergedLinks: '',
mergeDisabled: false,
loading: true,
error: false,
};
},
setStateForId(map, idOrIds, newState) {
const ids = toArray(idOrIds);
return ids.map(id => {
const state = (map.has(id) && map.get(id)) || {};
const mergedState = {...state, ...newState};
map.set(id, mergedState);
return mergedState;
});
},
isAllUnmergedSelected() {
const lockedItems =
(Array.from(this.unmergeState.values()) as Array<IdState>).filter(
({busy}) => busy
) || [];
return (
this.unmergeList.size ===
this.mergedItems.filter(({latestEvent}) => !!latestEvent).length -
lockedItems.length
);
},
// Fetches data
onFetch(toFetchArray) {
const requests = toFetchArray || this.toFetchArray;
// Reset state and trigger update
this.init();
this.triggerFetchState();
const promises = requests.map(
({endpoint, queryParams, dataKey}) =>
new Promise((resolve, reject) => {
this.api.request(endpoint, {
method: 'GET',
data: queryParams,
success: (data, _, resp) => {
resolve({
dataKey,
data,
links: resp ? resp.getResponseHeader('Link') : null,
});
},
error: err => {
const error = err.responseJSON?.detail || true;
reject(error);
},
});
})
);
const responseProcessors: ResponseProcessors = {
merged: items => {
const newItemsMap: Record<string, Fingerprint> = {};
const newItems: Fingerprint[] = [];
items.forEach(item => {
if (!newItemsMap[item.id]) {
const newItem = {
eventCount: 0,
children: [],
// lastSeen and latestEvent properties are correct
// since the server returns items in
// descending order of lastSeen
...item,
};
// Check for locked items
this.setStateForId(this.unmergeState, item.id, {
busy: item.state === 'locked',
});
newItemsMap[item.id] = newItem;
newItems.push(newItem);
}
const newItem = newItemsMap[item.id];
const {childId, childLabel, eventCount, lastSeen, latestEvent} = item;
if (eventCount) {
newItem.eventCount += eventCount;
}
if (childId) {
newItem.children.push({
childId,
childLabel,
lastSeen,
latestEvent,
eventCount,
});
}
});
return newItems;
},
similar: ([issue, scoreMap]) => {
// Check which similarity endpoint is being used
const hasSimilarityEmbeddingsFeature = requests[0]?.endpoint.includes(
'similar-issues-embeddings'
);
// Hide items with a low scores
const isBelowThreshold = hasSimilarityEmbeddingsFeature
? false
: checkBelowThreshold(scoreMap);
// List of scores indexed by interface (i.e., exception and message)
// Note: for v2, the interface is always "similarity". When v2 is
// rolled out we can get rid of this grouping entirely.
const scoresByInterface = Object.keys(scoreMap)
.map(scoreKey => [scoreKey, scoreMap[scoreKey]])
.reduce((acc, [scoreKey, score]) => {
// v1 layout: '<interface>:...'
const [interfaceName] = String(scoreKey).split(':');
if (!acc[interfaceName]) {
acc[interfaceName] = [];
}
acc[interfaceName].push([scoreKey, score]);
return acc;
}, {});
// Aggregate score by interface
const aggregate = Object.keys(scoresByInterface)
.map(interfaceName => [interfaceName, scoresByInterface[interfaceName]])
.reduce((acc, [interfaceName, allScores]) => {
// `null` scores means feature was not present in both issues, do not
// include in aggregate
const scores = allScores.filter(([, score]) => score !== null);
const avg = scores.reduce((sum, [, score]) => sum + score, 0) / scores.length;
acc[interfaceName] = hasSimilarityEmbeddingsFeature ? scores[0][1] : avg;
return acc;
}, {});
return {
issue,
score: scoreMap,
scoresByInterface,
aggregate,
isBelowThreshold,
};
},
};
if (toFetchArray) {
this.toFetchArray = toFetchArray;
}
return Promise.all(promises).then(
resultsArray => {
(resultsArray as ResultsAsArray).forEach(({dataKey, data, links}) => {
const items =
dataKey === 'similar'
? (data as ResultsAsArrayDataSimilar).map(responseProcessors[dataKey])
: responseProcessors[dataKey](data as ResultsAsArrayDataMerged);
this[`${dataKey}Items`] = items;
this[`${dataKey}Links`] = links;
});
this.loading = false;
this.error = false;
this.triggerFetchState();
},
() => {
this.loading = false;
this.error = true;
this.triggerFetchState();
}
);
},
// Toggle merge checkbox
onToggleMerge(id) {
let checked = false;
// Don't do anything if item is busy
const state = this.mergeState.has(id) ? this.mergeState.get(id) : undefined;
if (state?.busy === true) {
return;
}
if (this.mergeList.includes(id)) {
this.mergeList = this.mergeList.filter(item => item !== id);
} else {
this.mergeList = [...this.mergeList, id];
checked = true;
}
this.setStateForId(this.mergeState, id, {
checked,
});
this.triggerMergeState();
},
// Toggle unmerge check box
onToggleUnmerge([fingerprint, eventId]) {
let checked = false;
// Uncheck an item to unmerge
const state = this.unmergeState.get(fingerprint);
if (state?.busy === true) {
return;
}
if (this.unmergeList.has(fingerprint)) {
this.unmergeList.delete(fingerprint);
} else {
this.unmergeList.set(fingerprint, eventId);
checked = true;
}
// Update "checked" state for row
this.setStateForId(this.unmergeState, fingerprint, {
checked,
});
// Unmerge should be disabled if 0 or all items are selected, or if there's
// only one item to select
this.unmergeDisabled =
this.mergedItems.size <= 1 ||
this.unmergeList.size === 0 ||
this.isAllUnmergedSelected();
this.enableFingerprintCompare = this.unmergeList.size === 2;
this.triggerUnmergeState();
},
onUnmerge({groupId, loadingMessage, orgSlug, successMessage, errorMessage}) {
const ids = Array.from(this.unmergeList.keys()) as Array<string>;
return new Promise((resolve, reject) => {
if (this.isAllUnmergedSelected()) {
reject(new Error('Not allowed to unmerge ALL events'));
return;
}
// Disable unmerge button
this.unmergeDisabled = true;
// Disable rows
this.setStateForId(this.unmergeState, ids, {
checked: false,
busy: true,
});
this.triggerUnmergeState();
addLoadingMessage(loadingMessage);
this.api.request(`/organizations/${orgSlug}/issues/${groupId}/hashes/`, {
method: 'DELETE',
query: {
id: ids,
},
success: () => {
addSuccessMessage(successMessage);
// Busy rows after successful Unmerge
this.setStateForId(this.unmergeState, ids, {
checked: false,
busy: true,
});
this.unmergeList.clear();
},
error: error => {
errorMessage = error?.responseJSON?.detail || errorMessage;
addErrorMessage(errorMessage);
this.setStateForId(this.unmergeState, ids, {
checked: true,
busy: false,
});
},
complete: () => {
this.unmergeDisabled = false;
resolve(this.triggerUnmergeState());
},
});
});
},
// For cross-project views, we need to pass projectId instead of
// depending on router params (since we will only have orgId in that case)
onMerge({params, query, projectId}) {
if (!params) {
return undefined;
}
const ids = this.mergeList;
this.mergeDisabled = true;
this.setStateForId(this.mergeState, ids as Array<string>, {
busy: true,
});
this.triggerMergeState();
const promise = new Promise(resolve => {
// Disable merge button
const {orgId, groupId} = params;
mergeGroups(
this.api,
{
orgId,
projectId,
itemIds: [...ids, groupId],
query,
},
{
success: data => {
if (data?.merge?.parent) {
this.trigger({
mergedParent: data.merge.parent,
});
}
// Hide rows after successful merge
this.setStateForId(this.mergeState, ids as Array<string>, {
checked: false,
busy: true,
});
this.mergeList = [];
},
error: () => {
this.setStateForId(this.mergeState, ids as Array<string>, {
checked: true,
busy: false,
});
},
complete: () => {
this.mergeDisabled = false;
resolve(this.triggerMergeState());
},
}
);
});
return promise;
},
// Toggle collapsed state of all fingerprints
onToggleCollapseFingerprints() {
this.setStateForId(
this.unmergeState,
this.mergedItems.map(({id}) => id),
{
collapsed: !this.unmergeLastCollapsed,
}
);
this.unmergeLastCollapsed = !this.unmergeLastCollapsed;
this.trigger({
unmergeLastCollapsed: this.unmergeLastCollapsed,
unmergeState: this.unmergeState,
});
},
onToggleCollapseFingerprint(fingerprint) {
const collapsed =
this.unmergeState.has(fingerprint) && this.unmergeState.get(fingerprint).collapsed;
this.setStateForId(this.unmergeState, fingerprint, {collapsed: !collapsed});
this.trigger({
unmergeState: this.unmergeState,
});
},
triggerFetchState() {
const state = {
similarItems: this.similarItems.filter(({isBelowThreshold}) => !isBelowThreshold),
filteredSimilarItems: this.similarItems.filter(
({isBelowThreshold}) => isBelowThreshold
),
...pick(this, [
'mergedItems',
'mergedLinks',
'similarLinks',
'mergeState',
'unmergeState',
'loading',
'error',
'enableFingerprintCompare',
'unmergeList',
]),
};
this.trigger(state);
return state;
},
triggerUnmergeState() {
const state = pick(this, [
'unmergeDisabled',
'unmergeState',
'unmergeList',
'enableFingerprintCompare',
'unmergeLastCollapsed',
]);
this.trigger(state);
return state;
},
triggerMergeState() {
const state = pick(this, ['mergeDisabled', 'mergeState', 'mergeList']);
this.trigger(state);
return state;
},
getState(): State {
return {
...pick(this, [
'enableFingerprintCompare',
'error',
'filteredSimilarItems',
'loading',
'mergeDisabled',
'mergeList',
'mergeState',
'mergeState',
'mergedItems',
'mergedLinks',
'similarItems',
'similarLinks',
'unmergeDisabled',
'unmergeLastCollapsed',
'unmergeList',
'unmergeState',
]),
};
},
};
const GroupingStore = createStore(storeConfig);
export default GroupingStore;