mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import {useMemo} from 'react';
|
|
import {useTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import partition from 'lodash/partition';
|
|
|
|
import ChartZoom from 'sentry/components/charts/chartZoom';
|
|
import {LineChart} from 'sentry/components/charts/lineChart';
|
|
import {RELATIVE_DAYS_WINDOW} from 'sentry/components/events/eventStatisticalDetector/consts';
|
|
import * as SidebarSection from 'sentry/components/sidebarSection';
|
|
import {t} from 'sentry/locale';
|
|
import type {Event, EventsStatsData, Group, PageFilters} from 'sentry/types';
|
|
import {IssueType} from 'sentry/types';
|
|
import {defined} from 'sentry/utils';
|
|
import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
|
|
import type {MetaType} from 'sentry/utils/discover/eventView';
|
|
import EventView from 'sentry/utils/discover/eventView';
|
|
import {RateUnit} from 'sentry/utils/discover/fields';
|
|
import type {DiscoverQueryProps} from 'sentry/utils/discover/genericDiscoverQuery';
|
|
import {useGenericDiscoverQuery} from 'sentry/utils/discover/genericDiscoverQuery';
|
|
import {DiscoverDatasets} from 'sentry/utils/discover/types';
|
|
import {formatPercentage, formatRate} from 'sentry/utils/formatters';
|
|
import {useProfileEventsStats} from 'sentry/utils/profiling/hooks/useProfileEventsStats';
|
|
import {useRelativeDateTime} from 'sentry/utils/profiling/hooks/useRelativeDateTime';
|
|
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
|
|
import {useLocation} from 'sentry/utils/useLocation';
|
|
import useOrganization from 'sentry/utils/useOrganization';
|
|
import useRouter from 'sentry/utils/useRouter';
|
|
import {transformEventStats} from 'sentry/views/performance/trends/chart';
|
|
|
|
const BUCKET_SIZE = 6 * 60 * 60; // 6 hours in seconds;
|
|
|
|
type DataBucket = {
|
|
interval: number;
|
|
timestamp: number;
|
|
value: number;
|
|
};
|
|
|
|
interface EventThroughputProps {
|
|
event: Event;
|
|
group: Group;
|
|
}
|
|
|
|
export function EventThroughput({event, group}: EventThroughputProps) {
|
|
if (!isValidRegressionEvent(event, group)) {
|
|
return null;
|
|
}
|
|
|
|
return <EventThroughputInner event={event} group={group} />;
|
|
}
|
|
|
|
function EventThroughputInner({event, group}: EventThroughputProps) {
|
|
const theme = useTheme();
|
|
const router = useRouter();
|
|
|
|
const evidenceData = event.occurrence!.evidenceData;
|
|
const breakpoint = evidenceData.breakpoint;
|
|
|
|
const datetime = useRelativeDateTime({
|
|
anchor: breakpoint,
|
|
relativeDays: RELATIVE_DAYS_WINDOW,
|
|
});
|
|
|
|
const stats = useThroughputStats({datetime, event, group});
|
|
|
|
const [throughputBefore, throughputAfter, throughputDelta] = useMemo(() => {
|
|
const [beforeBuckets, afterBuckets] = partition(
|
|
stats.series,
|
|
item => item.timestamp < breakpoint
|
|
);
|
|
|
|
if (beforeBuckets.length <= 0 || afterBuckets.length <= 0) {
|
|
return [0, 0];
|
|
}
|
|
|
|
const beforeBucket = beforeBuckets.reduce((acc, cur) => {
|
|
if (!acc) {
|
|
return {...cur};
|
|
}
|
|
acc.value += cur.value;
|
|
acc.interval += cur.interval;
|
|
return acc;
|
|
}, null);
|
|
const afterBucket = afterBuckets.reduce((acc, cur) => {
|
|
if (!acc) {
|
|
return {...cur};
|
|
}
|
|
acc.value += cur.value;
|
|
acc.interval += cur.interval;
|
|
return acc;
|
|
}, null);
|
|
|
|
const before = beforeBucket.value / beforeBucket.interval;
|
|
const after = afterBucket.value / afterBucket.interval;
|
|
return [before, after, after / before - 1];
|
|
}, [breakpoint, stats.series]);
|
|
|
|
const series = useMemo(() => {
|
|
const result = transformEventStats(
|
|
stats.series.map(item => [item.timestamp, [{count: item.value / item.interval}]]),
|
|
'throughput()'
|
|
)[0];
|
|
|
|
result.markLine = {
|
|
data: [
|
|
{
|
|
xAxis: breakpoint * 1000,
|
|
},
|
|
],
|
|
label: {show: false},
|
|
lineStyle: {
|
|
color: theme.red300,
|
|
type: 'solid',
|
|
width: 2,
|
|
},
|
|
symbol: ['none', 'none'],
|
|
tooltip: {
|
|
show: false,
|
|
},
|
|
silent: true,
|
|
};
|
|
|
|
return [result];
|
|
}, [breakpoint, stats.series, theme]);
|
|
|
|
const chartOptions: Omit<
|
|
React.ComponentProps<typeof LineChart>,
|
|
'series'
|
|
> = useMemo(() => {
|
|
return {
|
|
grid: {
|
|
top: '20px',
|
|
bottom: '0px',
|
|
},
|
|
height: 100,
|
|
tooltip: {
|
|
valueFormatter: value => tooltipFormatter(value),
|
|
},
|
|
xAxis: {show: false, type: 'time'},
|
|
yAxis: {
|
|
axisLabel: {
|
|
color: theme.chartLabel,
|
|
formatter: (value: number) =>
|
|
axisLabelFormatter(value, 'rate', true, undefined, RateUnit.PER_SECOND),
|
|
},
|
|
splitLine: {show: false},
|
|
splitNumber: 1,
|
|
},
|
|
};
|
|
}, [theme]);
|
|
|
|
return (
|
|
<SidebarSection.Wrap data-test-id="throughput">
|
|
<SidebarSection.Title>{t('Change in Throughput')}</SidebarSection.Title>
|
|
{stats.series.length > 0 ? (
|
|
<CurrentLabel>{formatRate(throughputAfter, RateUnit.PER_SECOND)}</CurrentLabel>
|
|
) : (
|
|
<CurrentLabel>{'\u2014'}</CurrentLabel>
|
|
)}
|
|
{throughputDelta && throughputDelta > 0 ? (
|
|
<CompareLabel change="increase">
|
|
{t(
|
|
'Up %s from %s',
|
|
`+${formatPercentage(throughputDelta)}`,
|
|
formatRate(throughputBefore, RateUnit.PER_SECOND)
|
|
)}
|
|
</CompareLabel>
|
|
) : throughputDelta && throughputDelta < 0 ? (
|
|
<CompareLabel change="decrease">
|
|
{t(
|
|
'Down %s from %s',
|
|
formatPercentage(throughputDelta),
|
|
formatRate(throughputBefore, RateUnit.PER_SECOND)
|
|
)}
|
|
</CompareLabel>
|
|
) : stats.series.length > 0 ? (
|
|
<CompareLabel>
|
|
{t('Unchanged from', formatRate(throughputBefore, RateUnit.PER_SECOND))}
|
|
</CompareLabel>
|
|
) : (
|
|
<CompareLabel>{'\u2014'}</CompareLabel>
|
|
)}
|
|
<ChartZoom router={router} {...datetime}>
|
|
{zoomRenderProps => (
|
|
<LineChart {...zoomRenderProps} {...chartOptions} series={series} />
|
|
)}
|
|
</ChartZoom>
|
|
</SidebarSection.Wrap>
|
|
);
|
|
}
|
|
|
|
interface UseThroughputStatsOptions {
|
|
datetime: PageFilters['datetime'];
|
|
event: Event;
|
|
group: Group;
|
|
}
|
|
|
|
function useThroughputStats({datetime, event, group}: UseThroughputStatsOptions) {
|
|
const location = useLocation();
|
|
const organization = useOrganization();
|
|
|
|
const evidenceData = event.occurrence!.evidenceData;
|
|
|
|
const statsType = getStatsType(group);
|
|
|
|
// START Functions ====================
|
|
|
|
const functionStats = useProfileEventsStats({
|
|
dataset: 'profileFunctions',
|
|
datetime,
|
|
query: `fingerprint:${evidenceData?.fingerprint}`,
|
|
referrer: 'api.profiling.functions.regression.stats',
|
|
yAxes: ['count()'],
|
|
// only make query if statsType matches
|
|
enabled: statsType === 'functions',
|
|
});
|
|
|
|
const functionInterval = useMemo(() => {
|
|
const timestamps = functionStats?.data?.timestamps ?? [];
|
|
if (timestamps.length < 2) {
|
|
return null;
|
|
}
|
|
return timestamps[1] - timestamps[0];
|
|
}, [functionStats?.data]);
|
|
|
|
const functionData = useMemo(() => {
|
|
if (!functionInterval) {
|
|
return [];
|
|
}
|
|
|
|
const rawData = functionStats?.data?.data?.find(({axis}) => axis === 'count()');
|
|
const timestamps = functionStats?.data?.timestamps ?? [];
|
|
return timestamps.reduce((acc, timestamp, idx) => {
|
|
const bucket = Math.floor(timestamp / BUCKET_SIZE) * BUCKET_SIZE;
|
|
const prev: DataBucket = acc[acc.length - 1];
|
|
const value = rawData.values[idx];
|
|
|
|
if (prev?.timestamp === bucket) {
|
|
prev.value += value;
|
|
prev.interval += functionInterval;
|
|
} else {
|
|
acc.push({timestamp: bucket, value, interval: functionInterval});
|
|
}
|
|
return acc;
|
|
}, [] as DataBucket[]);
|
|
}, [functionStats?.data, functionInterval]);
|
|
|
|
// END Functions ====================
|
|
|
|
// START Transactions ====================
|
|
|
|
const eventView = useMemo(() => {
|
|
const view = EventView.fromLocation(location);
|
|
view.dataset = DiscoverDatasets.METRICS;
|
|
|
|
view.start = (datetime.start as Date).toISOString();
|
|
view.end = (datetime.end as Date).toISOString();
|
|
view.statsPeriod = undefined;
|
|
|
|
const query = new MutableSearch(['event.type:transaction']);
|
|
if (defined(evidenceData?.transaction)) {
|
|
query.setFilterValues('transaction', [evidenceData.transaction]);
|
|
}
|
|
view.query = query.formatString();
|
|
|
|
return view;
|
|
}, [location, evidenceData?.transaction, datetime]);
|
|
|
|
const transactionStats = useGenericDiscoverQuery<
|
|
{
|
|
data: EventsStatsData;
|
|
meta: MetaType;
|
|
},
|
|
DiscoverQueryProps
|
|
>({
|
|
route: 'events-stats',
|
|
location,
|
|
eventView,
|
|
orgSlug: organization.slug,
|
|
getRequestPayload: () => ({
|
|
// Manually inject y-axis for events-stats because
|
|
// getEventsAPIPayload doesn't pass it along
|
|
...eventView.getEventsAPIPayload(location),
|
|
yAxis: ['count()'],
|
|
}),
|
|
// only make query if statsType matches
|
|
options: {enabled: statsType === 'transactions'},
|
|
});
|
|
|
|
const transactionInterval = useMemo(() => {
|
|
const data = transactionStats?.data?.data ?? [];
|
|
if (data.length < 2) {
|
|
return null;
|
|
}
|
|
return data[1][0] - data[0][0];
|
|
}, [transactionStats?.data]);
|
|
|
|
const transactionData = useMemo(() => {
|
|
if (!transactionInterval) {
|
|
return [];
|
|
}
|
|
|
|
return (transactionStats?.data?.data ?? []).reduce((acc, curr) => {
|
|
const timestamp = curr[0];
|
|
const bucket = Math.floor(timestamp / BUCKET_SIZE) * BUCKET_SIZE;
|
|
const prev = acc[acc.length - 1];
|
|
const value = curr[1][0].count;
|
|
|
|
if (prev?.timestamp === bucket) {
|
|
prev.value += value;
|
|
prev.interval += transactionInterval;
|
|
} else {
|
|
acc.push({timestamp: bucket, value, interval: transactionInterval});
|
|
}
|
|
|
|
return acc;
|
|
}, [] as DataBucket[]);
|
|
}, [transactionInterval, transactionStats?.data]);
|
|
|
|
// END Transactions ====================
|
|
|
|
if (statsType === 'functions' && functionInterval) {
|
|
return {
|
|
isLoading: functionStats.isLoading,
|
|
isError: functionStats.isError,
|
|
series: functionData,
|
|
};
|
|
}
|
|
|
|
if (statsType === 'transactions' && transactionInterval) {
|
|
return {
|
|
isLoading: transactionStats.isLoading,
|
|
isError: transactionStats.isError,
|
|
series: transactionData,
|
|
};
|
|
}
|
|
|
|
return {
|
|
isLoading: false,
|
|
isError: false,
|
|
series: [],
|
|
};
|
|
}
|
|
|
|
function isValidRegressionEvent(event: Event, group: Group): boolean {
|
|
switch (group.issueType) {
|
|
case IssueType.PERFORMANCE_DURATION_REGRESSION:
|
|
case IssueType.PERFORMANCE_ENDPOINT_REGRESSION: {
|
|
const evidenceData = event.occurrence?.evidenceData;
|
|
return defined(evidenceData?.transaction) && defined(evidenceData?.breakpoint);
|
|
}
|
|
case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL:
|
|
case IssueType.PROFILE_FUNCTION_REGRESSION: {
|
|
const evidenceData = event.occurrence?.evidenceData;
|
|
return defined(evidenceData?.fingerprint) && defined(evidenceData?.breakpoint);
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getStatsType(group: Group): 'transactions' | 'functions' | null {
|
|
switch (group.issueType) {
|
|
case IssueType.PERFORMANCE_DURATION_REGRESSION:
|
|
case IssueType.PERFORMANCE_ENDPOINT_REGRESSION:
|
|
return 'transactions';
|
|
case IssueType.PROFILE_FUNCTION_REGRESSION_EXPERIMENTAL:
|
|
case IssueType.PROFILE_FUNCTION_REGRESSION:
|
|
return 'functions';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const CurrentLabel = styled('div')`
|
|
font-size: ${p => p.theme.fontSizeLarge};
|
|
`;
|
|
|
|
const CompareLabel = styled('div')<{change?: 'increase' | 'decrease'}>`
|
|
color: ${p =>
|
|
p.change === 'increase'
|
|
? p.theme.red300
|
|
: p.change === 'decrease'
|
|
? p.theme.green300
|
|
: p.theme.gray300};
|
|
`;
|