import {Fragment, useMemo} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import MarkLine from 'sentry/components/charts/components/markLine'; import ScatterSeries from 'sentry/components/charts/series/scatterSeries'; import {useHasNewTagsUI} from 'sentry/components/events/eventTags/util'; import type { MetricsSummary, MetricsSummaryItem, } from 'sentry/components/events/interfaces/spans/types'; import {Hovercard} from 'sentry/components/hovercard'; import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable'; import Link from 'sentry/components/links/link'; import {normalizeDateTimeString} from 'sentry/components/organizations/pageFilters/parse'; import {IconInfo} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { MetricsQueryApiResponseLastMeta, MetricType, MRI, Organization, } from 'sentry/types'; import {defined} from 'sentry/utils'; import {getDefaultAggregate, getMetricsUrl} from 'sentry/utils/metrics'; import {hasCustomMetrics} from 'sentry/utils/metrics/features'; import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters'; import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri'; import {MetricDisplayType} from 'sentry/utils/metrics/types'; import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery'; import {middleEllipsis} from 'sentry/utils/middleEllipsis'; import type {Color} from 'sentry/utils/theme'; import useOrganization from 'sentry/utils/useOrganization'; import {MetricChart} from 'sentry/views/metrics/chart/chart'; import type {Series} from 'sentry/views/metrics/chart/types'; import {getChartTimeseries} from 'sentry/views/metrics/widget'; import {getSampleChartSymbol} from 'sentry/views/starfish/views/spanSummaryPage/sampleList/durationChart/getSampleChartSymbol'; import { type SectionCardKeyValueList, TraceDrawerComponents, } from '../performance/newTraceDetails/traceDrawer/details/styles'; function flattenMetricsSummary( metricsSummary: MetricsSummary ): {item: MetricsSummaryItem; mri: MRI}[] { return ( Object.entries(metricsSummary) as [ keyof MetricsSummary, MetricsSummary[keyof MetricsSummary], ][] ).flatMap(([mri, items]) => (items || []).map(item => ({item, mri}))); } function tagToQuery(tagKey: string, tagValue: string) { return `${tagKey}:"${tagValue}"`; } const HALF_HOUR_IN_MS = 30 * 60 * 1000; interface DataRow { chartUnit: string; metricType: MetricType; metricUnit: string; mri: MRI; scalingFactor: number; summaryItem: MetricsSummaryItem; chartSeries?: Series; deviation?: number; deviationPercent?: number; itemAvg?: number; totalAvg?: number; } export function CustomMetricsEventData({ metricsSummary, startTimestamp, projectId, }: { projectId: string; startTimestamp: number; metricsSummary?: MetricsSummary; }) { const hasNewTagsUI = useHasNewTagsUI(); const organization = useOrganization(); const start = new Date(startTimestamp * 1000 - HALF_HOUR_IN_MS).toISOString(); const end = new Date(startTimestamp * 1000 + HALF_HOUR_IN_MS).toISOString(); const metricsSummaryEntries = useMemo( () => (metricsSummary ? flattenMetricsSummary(metricsSummary) : []), [metricsSummary] ); const queries = useMemo( () => metricsSummaryEntries.map((entry, index) => ({ mri: entry.mri, name: index.toString(), op: getDefaultAggregate(entry.mri), query: Object.entries(entry.item.tags ?? {}) .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue)) .join(' '), })), [metricsSummaryEntries] ); const {data} = useMetricsQuery(queries, { projects: [parseInt(projectId, 10)], datetime: {start, end, period: null, utc: true}, environments: [], }); const chartSeries = useMemo( () => data ? data.data.flatMap((entry, index) => { // Splitting the response to treat it like individual requests // TODO: improve utils for metric series generation return getChartTimeseries( {...data, data: [entry], meta: [data.meta[index]]}, [queries[index]], { showQuerySymbol: false, } ); }) : [], [data, queries] ); const dataRows = useMemo( () => metricsSummaryEntries .map((entry, index) => { const entryData = data?.data?.[index][0]; const dataMeta = data?.meta?.[index]; const lastMeta = dataMeta?.[ dataMeta?.length - 1 ] as MetricsQueryApiResponseLastMeta; const parsedMRI = parseMRI(entry.mri); const type = parsedMRI?.type || 'c'; const unit = parsedMRI?.unit || 'none'; const summaryItem = entry.item; const scalingFactor = lastMeta?.scaling_factor || 1; const totalAvg = entryData?.totals; const itemAvg = summaryItem.sum && summaryItem.count ? summaryItem.sum / summaryItem.count : undefined; const deviation = itemAvg && totalAvg ? itemAvg - totalAvg / scalingFactor : undefined; const deviationPercent = deviation && totalAvg ? deviation / (totalAvg / scalingFactor) : undefined; return { mri: entry.mri, itemAvg, totalAvg, scalingFactor, chartSeries: chartSeries[index], chartUnit: lastMeta?.unit ?? 'none', metricType: type, metricUnit: unit, summaryItem: summaryItem, deviation, deviationPercent, }; }) .sort((a, b) => { // Counters should be on bottom if (a.metricType === 'c' && b.metricType !== 'c') { return 1; } if (a.metricType !== 'c' && b.metricType === 'c') { return -1; } // Sort by highest absolute deviation return Math.abs(b.deviationPercent || 0) - Math.abs(a.deviationPercent || 0); }), [chartSeries, data?.data, data?.meta, metricsSummaryEntries] ); if (!hasCustomMetrics(organization) || metricsSummaryEntries.length === 0) { return null; } if (hasNewTagsUI) { const items: SectionCardKeyValueList = []; dataRows.forEach(dataRow => { const {mri, summaryItem} = dataRow; const name = formatMRI(mri); items.push({ key: `metric-${name}`, subject: name, value: ( {' '}
} linkText={t('View Metric')} linkTarget={getMetricsUrl(organization.slug, { start: normalizeDateTimeString(start), end: normalizeDateTimeString(end), interval: '10s', widgets: [ { mri: mri, displayType: MetricDisplayType.LINE, op: getDefaultAggregate(mri), query: Object.entries(summaryItem.tags ?? {}) .map(([tagKey, tagValue]) => tagToQuery(tagKey, tagValue)) .join(' '), }, ], })} /> ), }); }); return ( ); } return (
{t('Emitted Metrics')}
              {dataRows.map((dataRow, index) => (
                
                  
                  
{' '}


))}
); } function MetricRenderer({ dataRow, start, end, organization, }: { dataRow: DataRow; end: string; organization: Organization; start: string; }) { const {mri, summaryItem} = dataRow; return ( {t('Name:')}{' '} tagToQuery(tagKey, tagValue)) .join(' '), }, ], })} > {formatMRI(mri)} ); } function ValueRenderer({dataRow}: {dataRow: DataRow}) { const {mri, summaryItem} = dataRow; const parsedMRI = parseMRI(mri); const unit = parsedMRI?.unit ?? 'none'; const type = parsedMRI?.type ?? 'c'; // For counters the other stats offer little value, so we only show the count if (type === 'c' || !summaryItem.count) { return t('Count: %s', formatMetricUsingUnit(summaryItem.count, 'none')); } const avg = summaryItem.sum && summaryItem.count && summaryItem.sum / summaryItem.count; return ( {t('Value:')} {formatMetricUsingUnit(avg, unit) ?? t('(none)')} {summaryItem.count > 1 && ( } > )} ); } function DeviationRenderer({ dataRow, startTimestamp, }: { dataRow: DataRow; startTimestamp: number; }) { const { mri, totalAvg, itemAvg, deviation, deviationPercent, chartUnit, chartSeries, scalingFactor, } = dataRow; const theme = useTheme(); const parsedMRI = parseMRI(mri); const type = parsedMRI?.type ?? 'c'; if ( !defined(totalAvg) || !defined(itemAvg) || !defined(deviation) || !defined(deviationPercent) || type === 'c' ) { return null; } const totals = totalAvg / scalingFactor; const isPositive = deviation > 0; const isNeutral = Math.abs(deviationPercent) < 0.03; const valueColor: Color = isNeutral ? 'gray300' : isPositive ? 'red300' : 'green300'; const sign = deviation === 0 ? '±' : isPositive ? '+' : ''; const symbol = isNeutral ? '' : isPositive ? '▲' : '▼'; return ( {`avg(${middleEllipsis(formatMRI(mri), 40, /\.|-|_/)})`} {t("Span's start time -/+ 30 min")} } body={ chartSeries && ( { return `${formatMetricUsingUnit( (params.data as any).value[1], chartUnit || 'none' )}`; }, }, }, ], ...getSampleChartSymbol(itemAvg, totals, theme), symbolSize: 14, animation: false, silent: true, }), ]} height={160} /> ) } > {symbol} {sign} {formatMetricUsingUnit(deviationPercent * 100, 'percent')} ); } const STANDARD_TAGS = ['environment', 'release', 'transaction']; function TagsRenderer({tags}: {tags: Record | null}) { const tagString = Object.entries(tags || {}) .filter(([tagKey]) => !STANDARD_TAGS.includes(tagKey)) .reduce((acc, [tagKey, tagValue], index) => { if (index > 0) { acc += ', '; } acc += `${tagKey}:${tagValue}`; return acc; }, ''); if (tagString === '') { return ( {t('Tags:')} {t('(none)')} ); } return t('Tags: %s', tagString); } const ChartHovercard = styled(Hovercard)` width: 450px; `; const ValueCell = styled('div')` display: flex; align-items: center; font-family: ${p => p.theme.text.familyMono}; `; const NoValue = styled('span')` color: ${p => p.theme.gray300}; `; const ValueWrapper = styled(ValueCell)` display: inline-grid; grid-template-columns: max-content max-content; gap: ${space(1)}; align-items: center; `; const DeviationValue = styled('span')<{ textColor: Color; }>` color: ${p => p.theme[p.textColor]}; cursor: default; `; const HoverCardHeading = styled('div')` font-size: ${p => p.theme.fontSizeLarge}; padding-bottom: ${space(0.5)}; `; const HoverCardSubheading = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.subText}; `; const ValuesHovercard = styled(Hovercard)` width: 200px; & .hovercard-body { padding: ${space(0.5)}; } `; const StyledKeyValueTable = styled(KeyValueTable)` margin-bottom: 0; `;