123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757 |
- import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
- import styled from '@emotion/styled';
- import type {LocationDescriptorObject} from 'history';
- import debounce from 'lodash/debounce';
- import {Button, LinkButton} from 'sentry/components/button';
- import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
- import EmptyStateWarning from 'sentry/components/emptyStateWarning';
- import GridEditable, {
- COL_WIDTH_UNDEFINED,
- type GridColumnOrder,
- } from 'sentry/components/gridEditable';
- import SortLink from 'sentry/components/gridEditable/sortLink';
- import {Hovercard} from 'sentry/components/hovercard';
- import ProjectBadge from 'sentry/components/idBadge/projectBadge';
- import Link from 'sentry/components/links/link';
- import PerformanceDuration from 'sentry/components/performanceDuration';
- import {Flex} from 'sentry/components/profiling/flex';
- import SmartSearchBar from 'sentry/components/smartSearchBar';
- import {Tooltip} from 'sentry/components/tooltip';
- import {IconProfiling} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {DateString, MRI, PageFilters, ParsedMRI} from 'sentry/types';
- import {defined} from 'sentry/utils';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {Container, FieldDateTime, NumberContainer} from 'sentry/utils/discover/styles';
- import {getShortEventId} from 'sentry/utils/events';
- import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
- import {parseMRI} from 'sentry/utils/metrics/mri';
- import {
- type Field as SelectedField,
- getSummaryValueForOp,
- type MetricsSamplesResults,
- type ResultField,
- type Summary,
- useMetricsSamples,
- } from 'sentry/utils/metrics/useMetricsSamples';
- import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
- import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
- import Projects from 'sentry/utils/projects';
- import {decodeScalar} from 'sentry/utils/queryString';
- import {useLocation} from 'sentry/utils/useLocation';
- import useOrganization from 'sentry/utils/useOrganization';
- import usePageFilters from 'sentry/utils/usePageFilters';
- import useProjects from 'sentry/utils/useProjects';
- import type {SelectionRange} from 'sentry/views/metrics/chart/types';
- import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
- import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
- import ColorBar from 'sentry/views/performance/vitalDetail/colorBar';
- const fields: SelectedField[] = [
- 'project',
- 'id',
- 'span.op',
- 'span.description',
- 'span.duration',
- 'span.self_time',
- 'timestamp',
- 'trace',
- 'transaction',
- 'transaction.id',
- 'profile.id',
- ];
- export type Field = (typeof fields)[number];
- interface MetricsSamplesTableProps {
- focusArea?: SelectionRange;
- mri?: MRI;
- onRowHover?: (sampleId?: string) => void;
- op?: string;
- query?: string;
- setMetricsSamples?: React.Dispatch<
- React.SetStateAction<MetricsSamplesResults<Field>['data'] | undefined>
- >;
- sortKey?: string;
- }
- export function SearchableMetricSamplesTable({
- mri,
- query: primaryQuery,
- ...props
- }: MetricsSamplesTableProps) {
- const [secondaryQuery, setSecondaryQuery] = useState('');
- const handleSearch = useCallback(value => {
- setSecondaryQuery(value);
- }, []);
- const query = useMemo(() => {
- if (!secondaryQuery) {
- return primaryQuery;
- }
- return `${primaryQuery} ${secondaryQuery}`;
- }, [primaryQuery, secondaryQuery]);
- return (
- <Fragment>
- <MetricsSamplesSearchBar
- mri={mri}
- query={secondaryQuery}
- handleSearch={handleSearch}
- />
- <MetricSamplesTable mri={mri} query={query} {...props} />
- </Fragment>
- );
- }
- interface MetricsSamplesSearchBarProps {
- handleSearch: (string) => void;
- query: string;
- mri?: MRI;
- }
- export function MetricsSamplesSearchBar({
- handleSearch,
- mri,
- query,
- }: MetricsSamplesSearchBarProps) {
- const parsedMRI = useMemo(() => {
- if (!defined(mri)) {
- return null;
- }
- return parseMRI(mri);
- }, [mri]);
- const enabled = useMemo(() => {
- return parsedMRI?.useCase === 'transactions' || parsedMRI?.useCase === 'spans';
- }, [parsedMRI]);
- return (
- <SearchBar
- disabled={!enabled}
- query={query}
- onSearch={handleSearch}
- placeholder={
- enabled ? t('Filter by span tags') : t('Search not available for this metric')
- }
- />
- );
- }
- export function MetricSamplesTable({
- focusArea,
- mri,
- onRowHover,
- op,
- query,
- setMetricsSamples,
- sortKey = 'sort',
- }: MetricsSamplesTableProps) {
- const location = useLocation();
- const enabled = defined(mri);
- const parsedMRI = useMemo(() => {
- if (!defined(mri)) {
- return null;
- }
- return parseMRI(mri);
- }, [mri]);
- const datetime = useMemo(() => {
- if (!defined(focusArea) || !defined(focusArea.start) || !defined(focusArea.end)) {
- return undefined;
- }
- return {
- start: focusArea.start,
- end: focusArea.end,
- } as PageFilters['datetime'];
- }, [focusArea]);
- const currentSort = useMemo(() => {
- const value = decodeScalar(location.query[sortKey], '');
- if (!value) {
- return undefined;
- }
- const direction: 'asc' | 'desc' = value[0] === '-' ? 'desc' : 'asc';
- const key = direction === 'asc' ? value : value.substring(1);
- if (ALWAYS_SORTABLE_COLUMNS.has(key as ResultField)) {
- return {key, direction};
- }
- if (OPTIONALLY_SORTABLE_COLUMNS.has(key as ResultField)) {
- const column = getColumnForMRI(parsedMRI);
- if (column.key === key) {
- return {key, direction};
- }
- }
- return undefined;
- }, [location.query, parsedMRI, sortKey]);
- const sortQuery = useMemo(() => {
- if (!defined(currentSort)) {
- return undefined;
- }
- const direction = currentSort.direction === 'asc' ? '' : '-';
- return `${direction}${currentSort.key}`;
- }, [currentSort]);
- const result = useMetricsSamples({
- fields,
- datetime,
- max: focusArea?.max,
- min: focusArea?.min,
- mri,
- op,
- query,
- referrer: 'api.organization.metrics-samples',
- enabled,
- sort: sortQuery,
- limit: 20,
- });
- // propagate the metrics samples up as needed
- useEffect(() => {
- setMetricsSamples?.(result.data?.data ?? []);
- }, [result?.data?.data, setMetricsSamples]);
- const supportedMRI = useMemo(() => {
- const responseJSON = result.error?.responseJSON;
- if (typeof responseJSON?.detail !== 'string') {
- return true;
- }
- return !responseJSON?.detail?.startsWith('Unsupported MRI: ');
- }, [result]);
- const emptyMessage = useMemo(() => {
- if (!defined(mri)) {
- return (
- <EmptyStateWarning>
- <p>{t('Choose a metric to display samples')}</p>
- </EmptyStateWarning>
- );
- }
- return null;
- }, [mri]);
- const _renderPrependColumn = useMemo(() => {
- return renderPrependColumn();
- }, []);
- const _renderHeadCell = useMemo(() => {
- const generateSortLink = (key: string) => () => {
- if (!SORTABLE_COLUMNS.has(key as ResultField)) {
- return undefined;
- }
- let sort: string | undefined = undefined;
- if (defined(currentSort) && currentSort.key === key) {
- if (currentSort.direction === 'desc') {
- sort = key;
- }
- } else {
- sort = `-${key}`;
- }
- return {
- ...location,
- query: {
- ...location.query,
- sort,
- },
- };
- };
- return renderHeadCell(currentSort, generateSortLink);
- }, [currentSort, location]);
- const _renderBodyCell = useMemo(
- () => renderBodyCell(op, parsedMRI?.unit),
- [op, parsedMRI?.unit]
- );
- const wrapperRef = useRef<HTMLDivElement>(null);
- const handleMouseMove = useMemo(
- () =>
- debounce((event: React.MouseEvent) => {
- const wrapper = wrapperRef.current;
- const target = event.target;
- if (!wrapper || !(target instanceof Element)) {
- onRowHover?.(undefined);
- return;
- }
- const tableRow = (target as Element).closest('tbody >tr');
- if (!tableRow) {
- onRowHover?.(undefined);
- return;
- }
- const rows = Array.from(wrapper.querySelectorAll('tbody > tr'));
- const rowIndex = rows.indexOf(tableRow);
- const rowId = result.data?.data?.[rowIndex]?.id;
- if (!rowId) {
- onRowHover?.(undefined);
- return;
- }
- onRowHover?.(rowId);
- }, 10),
- [onRowHover, result.data?.data]
- );
- return (
- <div
- ref={wrapperRef}
- onMouseMove={handleMouseMove}
- onMouseLeave={() => onRowHover?.(undefined)}
- >
- <GridEditable
- isLoading={enabled && result.isLoading}
- error={enabled && result.isError && supportedMRI}
- data={result.data?.data ?? []}
- columnOrder={getColumnOrder(parsedMRI)}
- columnSortBy={[]}
- grid={{
- prependColumnWidths,
- renderPrependColumns: _renderPrependColumn,
- renderBodyCell: _renderBodyCell,
- renderHeadCell: _renderHeadCell,
- }}
- location={location}
- emptyMessage={emptyMessage}
- minimumColWidth={60}
- />
- </div>
- );
- }
- function getColumnForMRI(parsedMRI?: ParsedMRI | null): GridColumnOrder<ResultField> {
- return parsedMRI?.useCase === 'spans' && parsedMRI?.name === 'span.self_time'
- ? {key: 'span.self_time', width: COL_WIDTH_UNDEFINED, name: 'Self Time'}
- : parsedMRI?.useCase === 'transactions' && parsedMRI?.name === 'transaction.duration'
- ? {key: 'span.duration', width: COL_WIDTH_UNDEFINED, name: 'Duration'}
- : {key: 'summary', width: COL_WIDTH_UNDEFINED, name: parsedMRI?.name ?? 'Summary'};
- }
- function getColumnOrder(parsedMRI?: ParsedMRI | null): GridColumnOrder<ResultField>[] {
- const orders: (GridColumnOrder<ResultField> | undefined)[] = [
- {key: 'span.description', width: COL_WIDTH_UNDEFINED, name: 'Description'},
- {key: 'span.op', width: COL_WIDTH_UNDEFINED, name: 'Operation'},
- getColumnForMRI(parsedMRI),
- {key: 'timestamp', width: COL_WIDTH_UNDEFINED, name: 'Timestamp'},
- {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
- ];
- return orders.filter(
- (
- order: GridColumnOrder<ResultField> | undefined
- ): order is GridColumnOrder<ResultField> => !!order
- );
- }
- const RIGHT_ALIGNED_COLUMNS = new Set<ResultField>([
- 'span.duration',
- 'span.self_time',
- 'summary',
- ]);
- const ALWAYS_SORTABLE_COLUMNS = new Set<ResultField>(['timestamp']);
- const OPTIONALLY_SORTABLE_COLUMNS = new Set<ResultField>([
- 'summary',
- 'span.self_time',
- 'span.duration',
- ]);
- const SORTABLE_COLUMNS: Set<ResultField> = new Set([
- ...ALWAYS_SORTABLE_COLUMNS,
- ...OPTIONALLY_SORTABLE_COLUMNS,
- ]);
- const prependColumnWidths = ['40px'];
- function renderPrependColumn() {
- return function (
- isHeader: boolean,
- dataRow?: MetricsSamplesResults<SelectedField>['data'][number],
- _rowIndex?: number
- ) {
- if (isHeader) {
- return [null];
- }
- return [dataRow ? <ProjectRenderer projectSlug={dataRow.project} /> : null];
- };
- }
- function renderHeadCell(
- currentSort: {direction: 'asc' | 'desc'; key: string} | undefined,
- generateSortLink: (key) => () => LocationDescriptorObject | undefined
- ) {
- return function (col: GridColumnOrder<ResultField>) {
- return (
- <SortLink
- align={RIGHT_ALIGNED_COLUMNS.has(col.key) ? 'right' : 'left'}
- canSort={SORTABLE_COLUMNS.has(col.key)}
- direction={col.key === currentSort?.key ? currentSort?.direction : undefined}
- generateSortLink={generateSortLink(col.key)}
- title={col.name}
- />
- );
- };
- }
- function renderBodyCell(op?: string, unit?: string) {
- return function (
- col: GridColumnOrder<ResultField>,
- dataRow: MetricsSamplesResults<SelectedField>['data'][number]
- ) {
- if (col.key === 'span.description') {
- return (
- <SpanDescription
- description={dataRow['span.description']}
- project={dataRow.project}
- selfTime={dataRow['span.self_time']}
- duration={dataRow['span.duration']}
- spanId={dataRow.id}
- transaction={dataRow.transaction}
- transactionId={dataRow['transaction.id']}
- />
- );
- }
- if (col.key === 'span.self_time' || col.key === 'span.duration') {
- return <DurationRenderer duration={dataRow[col.key]} />;
- }
- if (col.key === 'summary') {
- return <SummaryRenderer summary={dataRow.summary} op={op} unit={unit} />;
- }
- if (col.key === 'timestamp') {
- return <TimestampRenderer timestamp={dataRow.timestamp} />;
- }
- if (col.key === 'trace') {
- return (
- <TraceId
- traceId={dataRow.trace}
- timestamp={dataRow.timestamp}
- eventId={dataRow.id}
- />
- );
- }
- if (col.key === 'profile.id') {
- return (
- <ProfileId projectSlug={dataRow.project} profileId={dataRow['profile.id']} />
- );
- }
- return <Container>{dataRow[col.key]}</Container>;
- };
- }
- function ProjectRenderer({projectSlug}: {projectSlug: string}) {
- const organization = useOrganization();
- return (
- <Container>
- <Projects orgId={organization.slug} slugs={[projectSlug]}>
- {({projects}) => {
- const project = projects.find(p => p.slug === projectSlug);
- return (
- <ProjectBadge
- project={project ? project : {slug: projectSlug}}
- avatarSize={16}
- hideName
- />
- );
- }}
- </Projects>
- </Container>
- );
- }
- function SpanDescription({
- description,
- duration,
- project,
- selfTime,
- spanId,
- transaction,
- transactionId,
- selfTimeColor = '#694D99',
- durationColor = 'gray100',
- }: {
- description: string;
- duration: number;
- project: string;
- selfTime: number;
- spanId: string;
- transaction: string;
- transactionId: string | null;
- durationColor?: string;
- selfTimeColor?: string;
- }) {
- const location = useLocation();
- const organization = useOrganization();
- const {projects} = useProjects({slugs: [project]});
- const transactionDetailsTarget = defined(transactionId)
- ? getTransactionDetailsUrl(
- organization.slug,
- `${project}:${transactionId}`,
- undefined,
- undefined,
- spanId
- )
- : undefined;
- const colorStops = useMemo(() => {
- const percentage = selfTime / duration;
- return [
- {color: selfTimeColor, percent: percentage},
- {color: durationColor, percent: 1 - percentage},
- ];
- }, [duration, selfTime, durationColor, selfTimeColor]);
- const transactionSummaryTarget = transactionSummaryRouteWithQuery({
- orgSlug: organization.slug,
- transaction,
- query: {
- ...location.query,
- query: undefined,
- },
- projectID: String(projects[0]?.id ?? ''),
- });
- let contents = description ? (
- <Fragment>{description}</Fragment>
- ) : (
- <EmptyValueContainer>{t('(no value)')}</EmptyValueContainer>
- );
- if (defined(transactionDetailsTarget)) {
- contents = <Link to={transactionDetailsTarget}>{contents}</Link>;
- }
- return (
- <Container>
- <StyledHovercard
- header={
- <Flex justify="space-between" align="center">
- {t('Span ID')}
- <SpanIdWrapper>
- {getShortEventId(spanId)}
- <CopyToClipboardButton borderless iconSize="xs" size="zero" text={spanId} />
- </SpanIdWrapper>
- </Flex>
- }
- body={
- <Flex gap={space(0.75)} column>
- <SectionTitle>{t('Duration')}</SectionTitle>
- <ColorBar colorStops={colorStops} />
- <Flex justify="space-between" align="center">
- <Flex justify="space-between" align="center" gap={space(0.5)}>
- <LegendDot color={selfTimeColor} />
- {t('Self Time: ')}
- <PerformanceDuration milliseconds={selfTime} abbreviation />
- </Flex>
- <Flex justify="space-between" align="center" gap={space(0.5)}>
- <LegendDot color={durationColor} />
- {t('Duration: ')}
- <PerformanceDuration milliseconds={duration} abbreviation />
- </Flex>
- </Flex>
- <SectionTitle>{t('Transaction')}</SectionTitle>
- <Tooltip containerDisplayMode="inline" showOnlyOnOverflow title={transaction}>
- <Link
- to={transactionSummaryTarget}
- onClick={() =>
- trackAnalytics('ddm.sample-table-interaction', {
- organization,
- target: 'description',
- })
- }
- >
- <TextOverflow>{transaction}</TextOverflow>
- </Link>
- </Tooltip>
- </Flex>
- }
- showUnderline
- >
- {contents}
- </StyledHovercard>
- </Container>
- );
- }
- function DurationRenderer({duration}: {duration: number}) {
- return (
- <NumberContainer>
- <PerformanceDuration milliseconds={duration} abbreviation />
- </NumberContainer>
- );
- }
- function SummaryRenderer({
- summary,
- op,
- unit,
- }: {
- summary: Summary;
- op?: string;
- unit?: string;
- }) {
- const value = getSummaryValueForOp(summary, op);
- // if the op is `count`, then the unit does not apply
- unit = op === 'count' ? '' : unit;
- return (
- <NumberContainer>{formatMetricUsingUnit(value ?? null, unit ?? '')}</NumberContainer>
- );
- }
- function TimestampRenderer({timestamp}: {timestamp: DateString}) {
- const location = useLocation();
- return (
- <FieldDateTime
- date={timestamp}
- year
- seconds
- timeZone
- utc={decodeScalar(location?.query?.utc) === 'true'}
- />
- );
- }
- function TraceId({
- traceId,
- timestamp,
- eventId,
- }: {
- traceId: string;
- eventId?: string;
- timestamp?: DateString;
- }) {
- const organization = useOrganization();
- const {selection} = usePageFilters();
- const stringOrNumberTimestamp =
- timestamp instanceof Date ? timestamp.toISOString() : timestamp ?? '';
- const target = getTraceDetailsUrl(
- organization,
- traceId,
- {
- start: selection.datetime.start,
- end: selection.datetime.end,
- statsPeriod: selection.datetime.period,
- },
- {},
- stringOrNumberTimestamp,
- eventId
- );
- return (
- <Container>
- <Link
- to={target}
- onClick={() =>
- trackAnalytics('ddm.sample-table-interaction', {
- organization,
- target: 'trace-id',
- })
- }
- >
- {getShortEventId(traceId)}
- </Link>
- </Container>
- );
- }
- function ProfileId({
- profileId,
- projectSlug,
- }: {
- profileId: string | null;
- projectSlug: string;
- }) {
- const organization = useOrganization();
- if (!defined(profileId)) {
- return (
- <Container>
- <Button href={undefined} disabled size="xs">
- <IconProfiling size="xs" />
- </Button>
- </Container>
- );
- }
- const target = generateProfileFlamechartRoute({
- orgSlug: organization.slug,
- projectSlug,
- profileId,
- });
- return (
- <Container>
- <LinkButton
- to={target}
- size="xs"
- onClick={() =>
- trackAnalytics('ddm.sample-table-interaction', {
- organization,
- target: 'profile',
- })
- }
- >
- <IconProfiling size="xs" />
- </LinkButton>
- </Container>
- );
- }
- const SearchBar = styled(SmartSearchBar)`
- margin-bottom: ${space(2)};
- `;
- const StyledHovercard = styled(Hovercard)`
- width: 350px;
- `;
- const SpanIdWrapper = styled('span')`
- font-weight: 400;
- `;
- const SectionTitle = styled('h6')`
- color: ${p => p.theme.subText};
- margin-bottom: 0;
- `;
- const TextOverflow = styled('span')`
- ${p => p.theme.overflowEllipsis};
- `;
- const LegendDot = styled('div')<{color: string}>`
- display: block;
- width: ${space(1)};
- height: ${space(1)};
- border-radius: 100%;
- background-color: ${p => p.theme[p.color] ?? p.color};
- `;
- const EmptyValueContainer = styled('span')`
- color: ${p => p.theme.gray300};
- `;
|