profileDetails.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {PlatformIcon} from 'platformicons';
  4. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {Button} from 'sentry/components/button';
  7. import {DateTime} from 'sentry/components/dateTime';
  8. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  9. import Link from 'sentry/components/links/link';
  10. import Version from 'sentry/components/version';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {Organization, Project} from 'sentry/types';
  14. import type {EventTransaction} from 'sentry/types/event';
  15. import {DeviceContextKey} from 'sentry/types/event';
  16. import {generateLinkToEventInTraceView} from 'sentry/utils/discover/urls';
  17. import type {FlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/reducers/flamegraphPreferences';
  18. import {useFlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphPreferences';
  19. import type {ProfileGroup} from 'sentry/utils/profiling/profile/importProfile';
  20. import {makeFormatter} from 'sentry/utils/profiling/units/units';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import useProjects from 'sentry/utils/useProjects';
  24. import type {UseResizableDrawerOptions} from 'sentry/utils/useResizableDrawer';
  25. import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
  26. import {formatVersion} from 'sentry/utils/versions/formatVersion';
  27. import {QuickContextHoverWrapper} from 'sentry/views/discover/table/quickContext/quickContextWrapper';
  28. import {ContextType} from 'sentry/views/discover/table/quickContext/utils';
  29. import {ProfilingDetailsFrameTabs, ProfilingDetailsListItem} from './flamegraphDrawer';
  30. function renderValue(
  31. key: string,
  32. value: number | string | undefined,
  33. profileGroup?: ProfileGroup
  34. ) {
  35. if (key === 'threads' && value === undefined) {
  36. return profileGroup?.profiles.length;
  37. }
  38. if (key === 'received') {
  39. return <DateTime date={value} />;
  40. }
  41. if (value === undefined || value === '') {
  42. return t('ø');
  43. }
  44. return value;
  45. }
  46. interface ProfileDetailsProps {
  47. profileGroup: ProfileGroup;
  48. projectId: string;
  49. transaction: EventTransaction | null;
  50. }
  51. export function ProfileDetails(props: ProfileDetailsProps) {
  52. const [detailsTab, setDetailsTab] = useState<'environment' | 'transaction'>(
  53. 'environment'
  54. );
  55. const organization = useOrganization();
  56. const {projects} = useProjects();
  57. const project = projects.find(
  58. p => p.id === String(props.profileGroup.metadata.projectID)
  59. );
  60. const onEnvironmentTabClick = useCallback(() => {
  61. setDetailsTab('environment');
  62. }, []);
  63. const onTransactionTabClick = useCallback(() => {
  64. setDetailsTab('transaction');
  65. }, []);
  66. const flamegraphPreferences = useFlamegraphPreferences();
  67. const isResizableDetailsBar =
  68. flamegraphPreferences.layout === 'table left' ||
  69. flamegraphPreferences.layout === 'table right';
  70. const detailsBarRef = useRef<HTMLDivElement>(null);
  71. const resizableOptions: UseResizableDrawerOptions = useMemo(() => {
  72. const isSidebarLayout =
  73. flamegraphPreferences.layout === 'table left' ||
  74. flamegraphPreferences.layout === 'table right';
  75. // Only used when in sidebar layout
  76. const initialSize = isSidebarLayout ? 260 : 0;
  77. const onResize = (newSize: number, maybeOldSize?: number) => {
  78. if (!detailsBarRef.current) {
  79. return;
  80. }
  81. if (isSidebarLayout) {
  82. detailsBarRef.current.style.width = `100%`;
  83. detailsBarRef.current.style.height = `${maybeOldSize ?? newSize}px`;
  84. } else {
  85. detailsBarRef.current.style.height = '';
  86. detailsBarRef.current.style.width = '';
  87. }
  88. };
  89. return {
  90. initialSize,
  91. onResize,
  92. direction: isSidebarLayout ? 'up' : 'left',
  93. min: 26,
  94. };
  95. }, [flamegraphPreferences.layout]);
  96. const {onMouseDown, onDoubleClick} = useResizableDrawer(resizableOptions);
  97. return (
  98. <ProfileDetailsBar ref={detailsBarRef} layout={flamegraphPreferences.layout}>
  99. <ProfilingDetailsFrameTabs>
  100. <ProfilingDetailsListItem
  101. size="sm"
  102. className={detailsTab === 'transaction' ? 'active' : undefined}
  103. >
  104. <Button
  105. data-title={t('Transaction')}
  106. priority="link"
  107. size="zero"
  108. onClick={onTransactionTabClick}
  109. >
  110. {t('Transaction')}
  111. </Button>
  112. </ProfilingDetailsListItem>
  113. <ProfilingDetailsListItem
  114. size="sm"
  115. className={detailsTab === 'environment' ? 'active' : undefined}
  116. >
  117. <Button
  118. data-title={t('Environment')}
  119. priority="link"
  120. size="zero"
  121. onClick={onEnvironmentTabClick}
  122. >
  123. {t('Environment')}
  124. </Button>
  125. </ProfilingDetailsListItem>
  126. <ProfilingDetailsListItem
  127. style={{
  128. flex: '1 1 100%',
  129. cursor: isResizableDetailsBar ? 'ns-resize' : undefined,
  130. }}
  131. onMouseDown={isResizableDetailsBar ? onMouseDown : undefined}
  132. onDoubleClick={isResizableDetailsBar ? onDoubleClick : undefined}
  133. />
  134. </ProfilingDetailsFrameTabs>
  135. {!props.transaction && detailsTab === 'environment' && (
  136. <ProfileEnvironmentDetails profileGroup={props.profileGroup} />
  137. )}
  138. {!props.transaction && detailsTab === 'transaction' && (
  139. <ProfileEventDetails
  140. organization={organization}
  141. profileGroup={props.profileGroup}
  142. project={project}
  143. transaction={props.transaction}
  144. />
  145. )}
  146. {props.transaction && detailsTab === 'environment' && (
  147. <TransactionDeviceDetails
  148. transaction={props.transaction}
  149. profileGroup={props.profileGroup}
  150. />
  151. )}
  152. {props.transaction && detailsTab === 'transaction' && (
  153. <TransactionEventDetails
  154. organization={organization}
  155. profileGroup={props.profileGroup}
  156. project={project}
  157. transaction={props.transaction}
  158. />
  159. )}
  160. </ProfileDetailsBar>
  161. );
  162. }
  163. function TransactionDeviceDetails({
  164. profileGroup,
  165. transaction,
  166. }: {
  167. profileGroup: ProfileGroup;
  168. transaction: EventTransaction;
  169. }) {
  170. const deviceDetails = useMemo(() => {
  171. const profileMetadata = profileGroup.metadata;
  172. const deviceContext = transaction.contexts.device;
  173. const osContext = transaction.contexts.os;
  174. const details: {
  175. key: string;
  176. label: string;
  177. value: React.ReactNode;
  178. }[] = [
  179. {
  180. key: 'model',
  181. label: t('Model'),
  182. value: deviceContext?.[DeviceContextKey.MODEL] ?? profileMetadata.deviceModel,
  183. },
  184. {
  185. key: 'manufacturer',
  186. label: t('Manufacturer'),
  187. value:
  188. deviceContext?.[DeviceContextKey.MANUFACTURER] ??
  189. profileMetadata.deviceManufacturer,
  190. },
  191. {
  192. key: 'classification',
  193. label: t('Classification'),
  194. value: profileMetadata.deviceClassification,
  195. },
  196. {
  197. key: 'name',
  198. label: t('OS'),
  199. value: osContext?.name ?? profileMetadata.deviceOSName,
  200. },
  201. {
  202. key: 'version',
  203. label: t('OS Version'),
  204. value: osContext?.version ?? profileMetadata.deviceOSVersion,
  205. },
  206. {
  207. key: 'locale',
  208. label: t('Locale'),
  209. value: profileMetadata.deviceLocale,
  210. },
  211. ];
  212. return details;
  213. }, [profileGroup, transaction]);
  214. return (
  215. <DetailsContainer>
  216. {deviceDetails.map(({key, label, value}) => (
  217. <DetailsRow key={key}>
  218. <strong>{label}:</strong>
  219. <span>{value || t('unknown')}</span>
  220. </DetailsRow>
  221. ))}
  222. </DetailsContainer>
  223. );
  224. }
  225. function TransactionEventDetails({
  226. organization,
  227. profileGroup,
  228. project,
  229. transaction,
  230. }: {
  231. organization: Organization;
  232. profileGroup: ProfileGroup;
  233. project: Project | undefined;
  234. transaction: EventTransaction;
  235. }) {
  236. const location = useLocation();
  237. const transactionDetails = useMemo(() => {
  238. const profileMetadata = profileGroup.metadata;
  239. const traceSlug = transaction.contexts?.trace?.trace_id ?? '';
  240. const transactionTarget =
  241. transaction.id && project && organization
  242. ? generateLinkToEventInTraceView({
  243. eventId: transaction.id,
  244. traceSlug,
  245. timestamp: transaction.endTimestamp,
  246. projectSlug: project.slug,
  247. location,
  248. organization,
  249. transactionName: transaction.title,
  250. })
  251. : null;
  252. const details: {
  253. key: string;
  254. label: string;
  255. value: React.ReactNode;
  256. }[] = [
  257. {
  258. key: 'transaction',
  259. label: t('Transaction'),
  260. value: transactionTarget ? (
  261. <Link to={transactionTarget}>{transaction.title}</Link>
  262. ) : (
  263. transaction.title
  264. ),
  265. },
  266. {
  267. key: 'timestamp',
  268. label: t('Timestamp'),
  269. value: <DateTime date={transaction.startTimestamp * 1000} />,
  270. },
  271. {
  272. key: 'project',
  273. label: t('Project'),
  274. value: project && <ProjectBadge project={project} avatarSize={12} />,
  275. },
  276. {
  277. key: 'release',
  278. label: t('Release'),
  279. value: transaction.release && (
  280. <QuickContextHoverWrapper
  281. dataRow={{release: transaction.release.version}}
  282. contextType={ContextType.RELEASE}
  283. organization={organization}
  284. >
  285. <Version version={transaction.release.version} truncate />
  286. </QuickContextHoverWrapper>
  287. ),
  288. },
  289. {
  290. key: 'environment',
  291. label: t('Environment'),
  292. value:
  293. transaction.tags.find(({key}) => key === 'environment')?.value ??
  294. profileMetadata.environment,
  295. },
  296. {
  297. key: 'duration',
  298. label: t('Duration'),
  299. value: msFormatter(
  300. (transaction.endTimestamp - transaction.startTimestamp) * 1000
  301. ),
  302. },
  303. {
  304. key: 'threads',
  305. label: t('Threads'),
  306. value: profileGroup.profiles.length,
  307. },
  308. ];
  309. return details;
  310. }, [organization, project, profileGroup, transaction, location]);
  311. return (
  312. <DetailsContainer>
  313. {transactionDetails.map(({key, label, value}) => (
  314. <DetailsRow key={key}>
  315. <strong>{label}:</strong>
  316. <span>{value || t('unknown')}</span>
  317. </DetailsRow>
  318. ))}
  319. </DetailsContainer>
  320. );
  321. }
  322. function ProfileEnvironmentDetails({profileGroup}: {profileGroup: ProfileGroup}) {
  323. return (
  324. <DetailsContainer>
  325. {Object.entries(ENVIRONMENT_DETAILS_KEY).map(([label, key]) => {
  326. const value = profileGroup.metadata[key];
  327. return (
  328. <DetailsRow key={key}>
  329. <strong>{label}:</strong>
  330. <span>{renderValue(key, value, profileGroup)}</span>
  331. </DetailsRow>
  332. );
  333. })}
  334. </DetailsContainer>
  335. );
  336. }
  337. function ProfileEventDetails({
  338. organization,
  339. profileGroup,
  340. project,
  341. transaction,
  342. }: {
  343. organization: Organization;
  344. profileGroup: ProfileGroup;
  345. project: Project | undefined;
  346. transaction: EventTransaction | null;
  347. }) {
  348. const location = useLocation();
  349. const traceSlug = transaction?.contexts?.trace?.trace_id ?? '';
  350. return (
  351. <DetailsContainer>
  352. {Object.entries(PROFILE_DETAILS_KEY).map(([label, key]) => {
  353. const value = profileGroup.metadata[key];
  354. if (key === 'organizationID') {
  355. if (organization) {
  356. return (
  357. <DetailsRow key={key}>
  358. <strong>{label}:</strong>
  359. <Link to={`/organizations/${organization.slug}/projects/`}>
  360. <span>
  361. <OrganizationAvatar size={12} organization={organization} />{' '}
  362. {organization.name}
  363. </span>
  364. </Link>
  365. </DetailsRow>
  366. );
  367. }
  368. }
  369. if (key === 'transactionName') {
  370. const transactionTarget =
  371. project?.slug && transaction?.id && organization
  372. ? generateLinkToEventInTraceView({
  373. traceSlug,
  374. projectSlug: project.slug,
  375. eventId: transaction.id,
  376. timestamp: transaction.endTimestamp,
  377. location,
  378. organization,
  379. })
  380. : null;
  381. if (transactionTarget) {
  382. return (
  383. <DetailsRow key={key}>
  384. <strong>{label}:</strong>
  385. <Link to={transactionTarget}>{value}</Link>
  386. </DetailsRow>
  387. );
  388. }
  389. }
  390. if (key === 'projectID') {
  391. if (project && organization) {
  392. return (
  393. <DetailsRow key={key}>
  394. <strong>{label}:</strong>
  395. <Link
  396. to={`/organizations/${organization.slug}/projects/${project.slug}/?project=${project.id}`}
  397. >
  398. <FlexRow>
  399. <ProjectAvatar project={project} size={12} /> {project.slug}
  400. </FlexRow>
  401. </Link>
  402. </DetailsRow>
  403. );
  404. }
  405. }
  406. if (key === 'release' && value) {
  407. const release = value;
  408. // If a release only contains a version key, then we cannot link to it and
  409. // fallback to just displaying the raw version value.
  410. if (!organization || (Object.keys(release).length <= 1 && release.version)) {
  411. return (
  412. <DetailsRow key={key}>
  413. <strong>{label}:</strong>
  414. <span>{formatVersion(release.version)}</span>
  415. </DetailsRow>
  416. );
  417. }
  418. return (
  419. <DetailsRow key={key}>
  420. <strong>{label}:</strong>
  421. <Link
  422. to={{
  423. pathname: `/organizations/${
  424. organization.slug
  425. }/releases/${encodeURIComponent(release.version)}/`,
  426. query: {
  427. project: profileGroup.metadata.projectID,
  428. },
  429. }}
  430. >
  431. {formatVersion(release.version)}
  432. </Link>
  433. </DetailsRow>
  434. );
  435. }
  436. // This final fallback is only capabable of rendering a string/undefined/null.
  437. // If the value is some other type, make sure not to let it reach here.
  438. return (
  439. <DetailsRow key={key}>
  440. <strong>{label}:</strong>
  441. <span>
  442. {key === 'platform' ? (
  443. <Fragment>
  444. <PlatformIcon size={12} platform={value ?? 'unknown'} />{' '}
  445. </Fragment>
  446. ) : null}
  447. {renderValue(key, value, profileGroup)}
  448. </span>
  449. </DetailsRow>
  450. );
  451. })}
  452. </DetailsContainer>
  453. );
  454. }
  455. const msFormatter = makeFormatter('milliseconds');
  456. const PROFILE_DETAILS_KEY: Record<string, string> = {
  457. [t('transaction')]: 'transactionName',
  458. [t('received at')]: 'received',
  459. [t('organization')]: 'organizationID',
  460. [t('project')]: 'projectID',
  461. [t('platform')]: 'platform',
  462. [t('release')]: 'release',
  463. [t('environment')]: 'environment',
  464. [t('threads')]: 'threads',
  465. };
  466. const ENVIRONMENT_DETAILS_KEY: Record<string, string> = {
  467. [t('model')]: 'deviceModel',
  468. [t('manufacturer')]: 'deviceManufacturer',
  469. [t('classification')]: 'deviceClassification',
  470. [t('os')]: 'deviceOSName',
  471. [t('os version')]: 'deviceOSVersion',
  472. [t('locale')]: 'deviceLocale',
  473. };
  474. // ProjectAvatar is contained in a div
  475. const FlexRow = styled('span')`
  476. display: inline-flex;
  477. align-items: center;
  478. > div {
  479. margin-right: ${space(0.5)};
  480. }
  481. `;
  482. const DetailsRow = styled('div')`
  483. white-space: nowrap;
  484. text-overflow: ellipsis;
  485. overflow: hidden;
  486. display: flex;
  487. align-items: center;
  488. font-size: ${p => p.theme.fontSizeSmall};
  489. > span,
  490. > a {
  491. min-width: 0;
  492. overflow: hidden;
  493. text-overflow: ellipsis;
  494. }
  495. > strong {
  496. margin-right: ${space(0.5)};
  497. }
  498. `;
  499. const DetailsContainer = styled('div')`
  500. padding: ${space(1)};
  501. margin: 0;
  502. overflow: auto;
  503. position: absolute;
  504. left: 0;
  505. top: 24px;
  506. width: 100%;
  507. height: calc(100% - 24px);
  508. `;
  509. const ProfileDetailsBar = styled('div')<{layout: FlamegraphPreferences['layout']}>`
  510. width: ${p =>
  511. p.layout === 'table left' || p.layout === 'table right' ? '100%' : '260px'};
  512. height: ${p =>
  513. p.layout === 'table left' || p.layout === 'table right' ? '220px' : '100%'};
  514. border-left: 1px solid ${p => p.theme.border};
  515. background: ${p => p.theme.background};
  516. grid-area: details;
  517. position: relative;
  518. > ul:first-child {
  519. border-bottom: 1px solid ${p => p.theme.border};
  520. }
  521. `;