actionableItems.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import type React from 'react';
  2. import {Fragment, useEffect, useMemo, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import startCase from 'lodash/startCase';
  5. import moment from 'moment';
  6. import Alert from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import type {EventErrorData} from 'sentry/components/events/errorItem';
  9. import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
  10. import List from 'sentry/components/list';
  11. import ListItem from 'sentry/components/list/listItem';
  12. import {
  13. GenericSchemaErrors,
  14. HttpProcessingErrors,
  15. JavascriptProcessingErrors,
  16. NativeProcessingErrors,
  17. ProguardProcessingErrors,
  18. } from 'sentry/constants/eventErrors';
  19. import {t} from 'sentry/locale';
  20. import {space} from 'sentry/styles/space';
  21. import type {Event, Project} from 'sentry/types';
  22. import {defined} from 'sentry/utils';
  23. import {trackAnalytics} from 'sentry/utils/analytics';
  24. import {getAnalyticsDataForEvent} from 'sentry/utils/events';
  25. import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import type {ActionableItemErrors, ActionableItemTypes} from './actionableItemsUtils';
  28. import {
  29. ActionableItemWarning,
  30. shouldErrorBeShown,
  31. useFetchProguardMappingFiles,
  32. } from './actionableItemsUtils';
  33. import type {ActionableItemsResponse} from './useActionableItems';
  34. import {useActionableItems} from './useActionableItems';
  35. interface ErrorMessage {
  36. desc: React.ReactNode;
  37. title: string;
  38. data?: {
  39. absPath?: string;
  40. image_path?: string;
  41. mage_name?: string;
  42. message?: string;
  43. name?: string;
  44. partialMatchPath?: string;
  45. sdk_time?: string;
  46. server_time?: string;
  47. url?: string;
  48. urlPrefix?: string;
  49. } & Record<string, any>;
  50. meta?: Record<string, any>;
  51. }
  52. const keyMapping = {
  53. image_uuid: 'Debug ID',
  54. image_name: 'File Name',
  55. image_path: 'File Path',
  56. };
  57. function getErrorMessage(
  58. error: ActionableItemErrors | EventErrorData,
  59. meta?: Record<string, any>
  60. ): Array<ErrorMessage> {
  61. const errorData = error.data ?? {};
  62. const metaData = meta ?? {};
  63. switch (error.type) {
  64. // Event Errors
  65. case ProguardProcessingErrors.PROGUARD_MISSING_LINENO:
  66. return [
  67. {
  68. title: t('A proguard mapping file does not contain line info'),
  69. desc: null,
  70. data: errorData,
  71. meta: metaData,
  72. },
  73. ];
  74. case ProguardProcessingErrors.PROGUARD_MISSING_MAPPING:
  75. return [
  76. {
  77. title: t('A proguard mapping file was missing'),
  78. desc: null,
  79. data: errorData,
  80. meta: metaData,
  81. },
  82. ];
  83. case NativeProcessingErrors.NATIVE_MISSING_OPTIONALLY_BUNDLED_DSYM:
  84. return [
  85. {
  86. title: t('An optional debug information file was missing'),
  87. desc: null,
  88. data: errorData,
  89. meta: metaData,
  90. },
  91. ];
  92. case NativeProcessingErrors.NATIVE_MISSING_DSYM:
  93. return [
  94. {
  95. title: t('A required debug information file was missing'),
  96. desc: null,
  97. data: errorData,
  98. meta: metaData,
  99. },
  100. ];
  101. case NativeProcessingErrors.NATIVE_BAD_DSYM:
  102. return [
  103. {
  104. title: t('The debug information file used was broken'),
  105. desc: null,
  106. data: errorData,
  107. meta: metaData,
  108. },
  109. ];
  110. case JavascriptProcessingErrors.JS_MISSING_SOURCES_CONTENT:
  111. return [
  112. {
  113. title: t('Missing Sources Context'),
  114. desc: null,
  115. data: errorData,
  116. meta: metaData,
  117. },
  118. ];
  119. case HttpProcessingErrors.FETCH_GENERIC_ERROR:
  120. return [
  121. {
  122. title: t('Unable to fetch HTTP resource'),
  123. desc: null,
  124. data: errorData,
  125. meta: metaData,
  126. },
  127. ];
  128. case HttpProcessingErrors.RESTRICTED_IP:
  129. return [
  130. {
  131. title: t('Cannot fetch resource due to restricted IP address'),
  132. desc: null,
  133. data: errorData,
  134. meta: metaData,
  135. },
  136. ];
  137. case HttpProcessingErrors.SECURITY_VIOLATION:
  138. return [
  139. {
  140. title: t('Cannot fetch resource due to security violation'),
  141. desc: null,
  142. data: errorData,
  143. meta: metaData,
  144. },
  145. ];
  146. case GenericSchemaErrors.FUTURE_TIMESTAMP:
  147. return [
  148. {
  149. title: t('Invalid timestamp (in future)'),
  150. desc: null,
  151. data: errorData,
  152. meta: metaData,
  153. },
  154. ];
  155. case GenericSchemaErrors.CLOCK_DRIFT:
  156. return [
  157. {
  158. title: t('Clock drift detected in SDK'),
  159. desc: null,
  160. data: errorData,
  161. meta: metaData,
  162. },
  163. ];
  164. case GenericSchemaErrors.PAST_TIMESTAMP:
  165. return [
  166. {
  167. title: t('Invalid timestamp (too old)'),
  168. desc: null,
  169. data: errorData,
  170. meta: metaData,
  171. },
  172. ];
  173. case GenericSchemaErrors.VALUE_TOO_LONG:
  174. return [
  175. {
  176. title: t('Discarded value due to exceeding maximum length'),
  177. desc: null,
  178. data: errorData,
  179. meta: metaData,
  180. },
  181. ];
  182. case GenericSchemaErrors.INVALID_DATA:
  183. return [
  184. {
  185. title: t('Discarded invalid value'),
  186. desc: null,
  187. data: errorData,
  188. meta: metaData,
  189. },
  190. ];
  191. case GenericSchemaErrors.INVALID_ENVIRONMENT:
  192. return [
  193. {
  194. title: t('Environment cannot contain "/" or newlines'),
  195. desc: null,
  196. data: errorData,
  197. meta: metaData,
  198. },
  199. ];
  200. case GenericSchemaErrors.INVALID_ATTRIBUTE:
  201. return [
  202. {
  203. title: t('Discarded unknown attribute'),
  204. desc: null,
  205. data: errorData,
  206. meta: metaData,
  207. },
  208. ];
  209. default:
  210. return [];
  211. }
  212. }
  213. interface ExpandableErrorListProps {
  214. errorList: ErrorMessageType[];
  215. handleExpandClick: (type: ActionableItemTypes) => void;
  216. }
  217. function ExpandableErrorList({handleExpandClick, errorList}: ExpandableErrorListProps) {
  218. const [expanded, setExpanded] = useState(false);
  219. const firstError = errorList[0];
  220. const {title, desc, type} = firstError;
  221. const numErrors = errorList.length;
  222. const errorDataList = errorList.map(error => error.data ?? {});
  223. const cleanedData = useMemo(() => {
  224. const cleaned = errorDataList.map(errorData => {
  225. const data = {...errorData};
  226. if (data.message === 'None') {
  227. // Python ensures a message string, but "None" doesn't make sense here
  228. delete data.message;
  229. }
  230. if (typeof data.image_path === 'string') {
  231. // Separate the image name for readability
  232. const separator = /^([a-z]:\\|\\\\)/i.test(data.image_path) ? '\\' : '/';
  233. const path = data.image_path.split(separator);
  234. data.image_name = path.splice(-1, 1)[0];
  235. data.image_path = path.length ? path.join(separator) + separator : '';
  236. }
  237. if (typeof data.server_time === 'string' && typeof data.sdk_time === 'string') {
  238. data.message = t(
  239. 'Adjusted timestamps by %s',
  240. moment
  241. .duration(moment.utc(data.server_time).diff(moment.utc(data.sdk_time)))
  242. .humanize()
  243. );
  244. }
  245. return Object.entries(data)
  246. .map(([key, value]) => ({
  247. key,
  248. value,
  249. subject: keyMapping[key] || startCase(key),
  250. }))
  251. .filter(d => {
  252. if (!d.value) {
  253. return true;
  254. }
  255. return !!d.value;
  256. });
  257. });
  258. return cleaned;
  259. // eslint-disable-next-line react-hooks/exhaustive-deps
  260. }, [errorDataList]);
  261. return (
  262. <List symbol="bullet">
  263. <StyledListItem>
  264. <ErrorTitleFlex>
  265. <strong>
  266. {title} ({numErrors})
  267. </strong>
  268. <ToggleButton
  269. priority="link"
  270. size="zero"
  271. onClick={() => {
  272. setExpanded(!expanded);
  273. handleExpandClick(type);
  274. }}
  275. >
  276. {expanded ? t('Collapse') : t('Expand')}
  277. </ToggleButton>
  278. </ErrorTitleFlex>
  279. {expanded && (
  280. <div>
  281. {desc && <Description>{desc}</Description>}
  282. {cleanedData.map((data, idx) => {
  283. return (
  284. <div key={idx}>
  285. <KeyValueList data={data} isContextData />
  286. {idx !== numErrors - 1 && <hr />}
  287. </div>
  288. );
  289. })}
  290. </div>
  291. )}
  292. </StyledListItem>
  293. </List>
  294. );
  295. }
  296. interface ErrorMessageType extends ErrorMessage {
  297. type: ActionableItemTypes;
  298. }
  299. function groupedErrors(
  300. event: Event,
  301. data?: ActionableItemsResponse,
  302. progaurdErrors?: EventErrorData[]
  303. ): Record<ActionableItemTypes, ErrorMessageType[]> | {} {
  304. if (!data || !progaurdErrors || !event) {
  305. return {};
  306. }
  307. const {_meta} = event;
  308. const errors = [...data.errors, ...progaurdErrors]
  309. .filter(error => shouldErrorBeShown(error, event))
  310. .flatMap((error, errorIdx) =>
  311. getErrorMessage(error, _meta?.errors?.[errorIdx]).map(message => ({
  312. ...message,
  313. type: error.type,
  314. }))
  315. );
  316. const grouped = errors.reduce((rv, error) => {
  317. rv[error.type] = rv[error.type] || [];
  318. rv[error.type].push(error);
  319. return rv;
  320. }, Object.create(null));
  321. return grouped;
  322. }
  323. interface ActionableItemsProps {
  324. event: Event;
  325. isShare: boolean;
  326. project: Project;
  327. }
  328. export function ActionableItems({event, project, isShare}: ActionableItemsProps) {
  329. const organization = useOrganization();
  330. const {data, isLoading} = useActionableItems({
  331. eventId: event.id,
  332. orgSlug: organization.slug,
  333. projectSlug: project.slug,
  334. });
  335. const {proguardErrorsLoading, proguardErrors} = useFetchProguardMappingFiles({
  336. event,
  337. project,
  338. isShare,
  339. });
  340. useEffect(() => {
  341. if (proguardErrors?.length) {
  342. if (proguardErrors[0]?.type === 'proguard_potentially_misconfigured_plugin') {
  343. trackAnalytics('issue_error_banner.proguard_misconfigured.displayed', {
  344. organization,
  345. group: event?.groupID,
  346. platform: project.platform,
  347. });
  348. } else if (proguardErrors[0]?.type === 'proguard_missing_mapping') {
  349. trackAnalytics('issue_error_banner.proguard_missing_mapping.displayed', {
  350. organization,
  351. group: event?.groupID,
  352. platform: project.platform,
  353. });
  354. }
  355. }
  356. // Just for analytics, only track this once per visit
  357. // eslint-disable-next-line react-hooks/exhaustive-deps
  358. }, []);
  359. const errorMessages = groupedErrors(event, data, proguardErrors);
  360. useRouteAnalyticsParams({
  361. show_actionable_items_cta: data ? data.errors.length > 0 : false,
  362. actionable_items: data ? Object.keys(errorMessages) : [],
  363. });
  364. if (
  365. isLoading ||
  366. !defined(data) ||
  367. data.errors.length === 0 ||
  368. Object.keys(errorMessages).length === 0
  369. ) {
  370. return null;
  371. }
  372. if (proguardErrorsLoading) {
  373. // XXX: This is necessary for acceptance tests to wait until removal since there is
  374. // no visual loading state.
  375. return <HiddenDiv data-test-id="event-errors-loading" />;
  376. }
  377. const analyticsParams = {
  378. organization,
  379. project_id: event.projectID,
  380. group_id: event.groupID,
  381. ...getAnalyticsDataForEvent(event),
  382. };
  383. const handleExpandClick = (type: ActionableItemTypes) => {
  384. trackAnalytics('actionable_items.expand_clicked', {
  385. ...analyticsParams,
  386. type,
  387. });
  388. };
  389. const hasErrorAlert = Object.keys(errorMessages).some(
  390. error =>
  391. !ActionableItemWarning.includes(
  392. error as ProguardProcessingErrors | NativeProcessingErrors | GenericSchemaErrors
  393. )
  394. );
  395. for (const errorKey in Object.keys(errorMessages)) {
  396. const isWarning = ActionableItemWarning.includes(
  397. errorKey as ProguardProcessingErrors | NativeProcessingErrors | GenericSchemaErrors
  398. );
  399. const shouldDelete = hasErrorAlert ? isWarning : !isWarning;
  400. if (shouldDelete) {
  401. delete errorMessages[errorKey];
  402. }
  403. }
  404. return (
  405. <StyledAlert
  406. defaultExpanded
  407. showIcon
  408. type={hasErrorAlert ? 'error' : 'warning'}
  409. expand={
  410. <Fragment>
  411. {Object.keys(errorMessages).map((error, idx) => {
  412. return (
  413. <ExpandableErrorList
  414. key={idx}
  415. errorList={errorMessages[error]}
  416. handleExpandClick={handleExpandClick}
  417. />
  418. );
  419. })}
  420. </Fragment>
  421. }
  422. >
  423. {hasErrorAlert
  424. ? t('Sentry has identified the following problems for you to fix')
  425. : t('Sentry has identified the following problems for you to monitor')}
  426. </StyledAlert>
  427. );
  428. }
  429. const Description = styled('div')`
  430. margin-top: ${space(0.5)};
  431. `;
  432. const StyledAlert = styled(Alert)`
  433. margin: 0 30px;
  434. `;
  435. const StyledListItem = styled(ListItem)`
  436. margin-bottom: ${space(0.75)};
  437. `;
  438. const ToggleButton = styled(Button)`
  439. color: ${p => p.theme.subText};
  440. text-decoration: underline;
  441. :hover,
  442. :focus {
  443. color: ${p => p.theme.textColor};
  444. }
  445. `;
  446. const ErrorTitleFlex = styled('div')`
  447. display: flex;
  448. justify-content: space-between;
  449. align-items: center;
  450. gap: ${space(1)};
  451. `;
  452. const HiddenDiv = styled('div')`
  453. display: none;
  454. `;