mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
689 lines
19 KiB
TypeScript
689 lines
19 KiB
TypeScript
import {PureComponent} from 'react';
|
|
import isEqual from 'lodash/isEqual';
|
|
import omitBy from 'lodash/omitBy';
|
|
|
|
import {doEventsRequest} from 'sentry/actionCreators/events';
|
|
import {addErrorMessage} from 'sentry/actionCreators/indicator';
|
|
import type {Client} from 'sentry/api';
|
|
import LoadingPanel from 'sentry/components/charts/loadingPanel';
|
|
import {
|
|
canIncludePreviousPeriod,
|
|
getPreviousSeriesName,
|
|
isMultiSeriesStats,
|
|
} from 'sentry/components/charts/utils';
|
|
import {t} from 'sentry/locale';
|
|
import type {
|
|
DateString,
|
|
EventsStats,
|
|
EventsStatsData,
|
|
MultiSeriesEventsStats,
|
|
OrganizationSummary,
|
|
} from 'sentry/types';
|
|
import type {Series, SeriesDataUnit} from 'sentry/types/echarts';
|
|
import {defined} from 'sentry/utils';
|
|
import {DURATION_UNITS, SIZE_UNITS} from 'sentry/utils/discover/fieldRenderers';
|
|
import type {AggregationOutputType} from 'sentry/utils/discover/fields';
|
|
import {getAggregateAlias, stripEquationPrefix} from 'sentry/utils/discover/fields';
|
|
import type {DiscoverDatasets} from 'sentry/utils/discover/types';
|
|
import type {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';
|
|
|
|
export type TimeSeriesData = {
|
|
allTimeseriesData?: EventsStatsData;
|
|
comparisonTimeseriesData?: Series[];
|
|
originalPreviousTimeseriesData?: EventsStatsData | null;
|
|
originalTimeseriesData?: EventsStatsData;
|
|
previousTimeseriesData?: Series[] | null;
|
|
timeAggregatedData?: Series | {};
|
|
timeframe?: {end: number; start: number};
|
|
// timeseries data
|
|
timeseriesData?: Series[];
|
|
timeseriesResultsTypes?: Record<string, AggregationOutputType>;
|
|
timeseriesTotals?: {count: number};
|
|
yAxis?: string | string[];
|
|
};
|
|
|
|
type LoadingStatus = {
|
|
/**
|
|
* Whether there was an error retrieving data
|
|
*/
|
|
errored: boolean;
|
|
loading: boolean;
|
|
reloading: boolean;
|
|
errorMessage?: string;
|
|
};
|
|
|
|
// Can hold additional data from the root an events stat object (eg. start, end, order, isMetricsData).
|
|
interface AdditionalSeriesInfo {
|
|
isExtrapolatedData?: boolean;
|
|
isMetricsData?: boolean;
|
|
}
|
|
|
|
export type RenderProps = LoadingStatus &
|
|
TimeSeriesData & {
|
|
results?: Series[]; // Chart with multiple series.
|
|
seriesAdditionalInfo?: Record<string, AdditionalSeriesInfo>;
|
|
};
|
|
|
|
type DefaultProps = {
|
|
/**
|
|
* Include data for previous period
|
|
*/
|
|
includePrevious: boolean;
|
|
/**
|
|
* Transform the response data to be something ingestible by charts
|
|
*/
|
|
includeTransformedData: boolean;
|
|
/**
|
|
* Interval to group results in
|
|
*
|
|
* e.g. 1d, 1h, 1m, 1s
|
|
*/
|
|
interval: string;
|
|
/**
|
|
* number of rows to return
|
|
*/
|
|
limit: number;
|
|
/**
|
|
* The query string to search events by
|
|
*/
|
|
query: string;
|
|
/**
|
|
* Time delta for comparing intervals of alert metrics, in seconds
|
|
*/
|
|
comparisonDelta?: number;
|
|
/**
|
|
* Absolute end date for query
|
|
*/
|
|
end?: DateString;
|
|
/**
|
|
* Relative time period for query.
|
|
*
|
|
* Use `start` and `end` for absolute dates.
|
|
*
|
|
* e.g. 24h, 7d, 30d
|
|
*/
|
|
period?: string | null;
|
|
/**
|
|
* Absolute start date for query
|
|
*/
|
|
start?: DateString;
|
|
};
|
|
|
|
type EventsRequestPartialProps = {
|
|
/**
|
|
* API client instance
|
|
*/
|
|
api: Client;
|
|
children: (renderProps: RenderProps) => React.ReactNode;
|
|
organization: OrganizationSummary;
|
|
/**
|
|
* Whether or not to include the last partial bucket. This happens for example when the
|
|
* current time is 11:26 and the last bucket ranges from 11:25-11:30. This means that
|
|
* the last bucket contains 1 minute worth of data while the rest contains 5 minutes.
|
|
*
|
|
* This flag indicates whether or not this last bucket should be included in the result.
|
|
*/
|
|
partial: boolean;
|
|
/**
|
|
* Discover needs confirmation to run >30 day >10 project queries,
|
|
* optional and when not passed confirmation is not required.
|
|
*/
|
|
confirmedQuery?: boolean;
|
|
/**
|
|
* Name used for display current series dataset tooltip
|
|
*/
|
|
currentSeriesNames?: string[];
|
|
/**
|
|
* Optional callback to further process raw events request response data
|
|
*/
|
|
dataLoadedCallback?: (any: EventsStats | MultiSeriesEventsStats | null) => void;
|
|
/**
|
|
* Specify the dataset to query from. Defaults to discover.
|
|
*/
|
|
dataset?: DiscoverDatasets;
|
|
/**
|
|
* List of environments to query
|
|
*/
|
|
environment?: Readonly<string[]>;
|
|
/**
|
|
* Is query out of retention
|
|
*/
|
|
expired?: boolean;
|
|
/**
|
|
* List of fields to group with when doing a topEvents request.
|
|
*/
|
|
field?: string[];
|
|
/**
|
|
* Allows overriding the pathname.
|
|
*/
|
|
generatePathname?: (org: OrganizationSummary) => string;
|
|
/**
|
|
* Hide error toast (used for pages which also query discover). Stops error appearing as a toast.
|
|
*/
|
|
hideError?: boolean;
|
|
/**
|
|
* Initial loading state
|
|
*/
|
|
loading?: boolean;
|
|
/**
|
|
* Query name used for displaying error toast if it is out of retention
|
|
*/
|
|
name?: string;
|
|
/**
|
|
* A way to control error if error handling is not owned by the toast.
|
|
*/
|
|
onError?: (error: string) => void;
|
|
/**
|
|
* How to order results when getting top events.
|
|
*/
|
|
orderby?: string;
|
|
previousSeriesNames?: string[];
|
|
/**
|
|
* List of project ids to query
|
|
*/
|
|
project?: Readonly<number[]>;
|
|
/**
|
|
* A container for query batching data and functions.
|
|
*/
|
|
queryBatching?: QueryBatching;
|
|
/**
|
|
* Extra query parameters to be added.
|
|
*/
|
|
queryExtras?: Record<string, string>;
|
|
/**
|
|
* A unique name for what's triggering this request, see organization_events_stats for an allowlist
|
|
*/
|
|
referrer?: string;
|
|
/**
|
|
* Sample rate used for data extrapolation in OnDemandMetricsRequest
|
|
*/
|
|
sampleRate?: number;
|
|
/**
|
|
* Should loading be shown.
|
|
*/
|
|
showLoading?: boolean;
|
|
/**
|
|
* List of team ids to query
|
|
*/
|
|
team?: Readonly<string | string[]>;
|
|
/**
|
|
* The number of top results to get. When set a multi-series result will be returned
|
|
* in the `results` child render function.
|
|
*/
|
|
topEvents?: number;
|
|
/**
|
|
* Whether or not to use on demand metrics
|
|
* This is a temporary flag to allow us to test on demand metrics
|
|
*/
|
|
useOnDemandMetrics?: boolean;
|
|
/**
|
|
* Whether or not to zerofill results
|
|
*/
|
|
withoutZerofill?: boolean;
|
|
/**
|
|
* The yAxis being plotted. If multiple yAxis are requested,
|
|
* the child render function will be called with `results`
|
|
*/
|
|
yAxis?: string | string[];
|
|
};
|
|
|
|
interface EventsRequestPropsWithTimeAggregation
|
|
extends DefaultProps,
|
|
EventsRequestPartialProps {
|
|
includeTimeAggregation: true;
|
|
timeAggregationSeriesName: string;
|
|
}
|
|
|
|
interface EventsRequestPropsWithoutTimeAggregation
|
|
extends DefaultProps,
|
|
EventsRequestPartialProps {
|
|
includeTimeAggregation?: false;
|
|
timeAggregationSeriesName?: undefined;
|
|
}
|
|
|
|
export type EventsRequestProps =
|
|
| EventsRequestPropsWithTimeAggregation
|
|
| EventsRequestPropsWithoutTimeAggregation;
|
|
|
|
type EventsRequestState = {
|
|
errored: boolean;
|
|
fetchedWithPrevious: boolean;
|
|
reloading: boolean;
|
|
timeseriesData: null | EventsStats | MultiSeriesEventsStats;
|
|
errorMessage?: string;
|
|
};
|
|
|
|
const propNamesToIgnore = [
|
|
'api',
|
|
'children',
|
|
'organization',
|
|
'loading',
|
|
'queryBatching',
|
|
'generatePathname',
|
|
];
|
|
const omitIgnoredProps = (props: EventsRequestProps) =>
|
|
omitBy(props, (_value, key) => propNamesToIgnore.includes(key));
|
|
|
|
class EventsRequest extends PureComponent<EventsRequestProps, EventsRequestState> {
|
|
static defaultProps: DefaultProps = {
|
|
period: undefined,
|
|
start: null,
|
|
end: null,
|
|
interval: '1d',
|
|
comparisonDelta: undefined,
|
|
limit: 15,
|
|
query: '',
|
|
includePrevious: true,
|
|
includeTransformedData: true,
|
|
};
|
|
|
|
state: EventsRequestState = {
|
|
reloading: !!this.props.loading,
|
|
errored: false,
|
|
timeseriesData: null,
|
|
fetchedWithPrevious: false,
|
|
};
|
|
|
|
componentDidMount() {
|
|
this.fetchData();
|
|
}
|
|
|
|
componentDidUpdate(prevProps: EventsRequestProps) {
|
|
if (isEqual(omitIgnoredProps(prevProps), omitIgnoredProps(this.props))) {
|
|
return;
|
|
}
|
|
this.fetchData();
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.unmounting = true;
|
|
}
|
|
|
|
private unmounting: boolean = false;
|
|
|
|
fetchData = async () => {
|
|
const {api, confirmedQuery, onError, expired, name, hideError, ...props} = this.props;
|
|
let timeseriesData: EventsStats | MultiSeriesEventsStats | null = null;
|
|
|
|
if (confirmedQuery === false) {
|
|
return;
|
|
}
|
|
|
|
this.setState(state => ({
|
|
reloading: state.timeseriesData !== null,
|
|
errored: false,
|
|
errorMessage: undefined,
|
|
}));
|
|
|
|
let errorMessage;
|
|
if (expired) {
|
|
errorMessage = t(
|
|
'%s has an invalid date range. Please try a more recent date range.',
|
|
name
|
|
);
|
|
addErrorMessage(errorMessage, {append: true});
|
|
|
|
this.setState({
|
|
errored: true,
|
|
errorMessage,
|
|
});
|
|
} else {
|
|
try {
|
|
api.clear();
|
|
timeseriesData = await doEventsRequest(api, props);
|
|
} catch (resp) {
|
|
if (resp?.responseJSON?.detail) {
|
|
errorMessage = resp.responseJSON.detail;
|
|
} else {
|
|
errorMessage = t('Error loading chart data');
|
|
}
|
|
if (!hideError) {
|
|
addErrorMessage(errorMessage);
|
|
}
|
|
if (onError) {
|
|
onError(errorMessage);
|
|
}
|
|
this.setState({
|
|
errored: true,
|
|
errorMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (this.unmounting) {
|
|
return;
|
|
}
|
|
|
|
this.setState({
|
|
reloading: false,
|
|
timeseriesData,
|
|
fetchedWithPrevious: props.includePrevious,
|
|
});
|
|
if (props.dataLoadedCallback) {
|
|
props.dataLoadedCallback(timeseriesData);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves dataset for the current period (since data can potentially
|
|
* contain previous period's data), as well as the previous period if
|
|
* possible.
|
|
*
|
|
* Returns `null` if data does not exist
|
|
*/
|
|
getData = (
|
|
data: EventsStatsData = []
|
|
): {current: EventsStatsData; previous: EventsStatsData | null} => {
|
|
const {fetchedWithPrevious} = this.state;
|
|
const {period, includePrevious} = this.props;
|
|
|
|
const hasPreviousPeriod =
|
|
fetchedWithPrevious || canIncludePreviousPeriod(includePrevious, period);
|
|
// Take the floor just in case, but data should always be divisible by 2
|
|
const dataMiddleIndex = Math.floor(data.length / 2);
|
|
return {
|
|
current: hasPreviousPeriod ? data.slice(dataMiddleIndex) : data,
|
|
previous: hasPreviousPeriod ? data.slice(0, dataMiddleIndex) : null,
|
|
};
|
|
};
|
|
|
|
// This aggregates all values per `timestamp`
|
|
calculateTotalsPerTimestamp(
|
|
data: EventsStatsData,
|
|
getName: (
|
|
timestamp: number,
|
|
countArray: {count: number}[],
|
|
i: number
|
|
) => number = timestamp => timestamp * 1000
|
|
): SeriesDataUnit[] {
|
|
return data.map(([timestamp, countArray], i) => ({
|
|
name: getName(timestamp, countArray, i),
|
|
value: countArray.reduce((acc, {count}) => acc + count, 0),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get previous period data, but transform timestamps so that data fits unto
|
|
* the current period's data axis
|
|
*/
|
|
transformPreviousPeriodData(
|
|
current: EventsStatsData,
|
|
previous: EventsStatsData | null,
|
|
seriesName?: string
|
|
): Series | null {
|
|
// Need the current period data array so we can take the timestamp
|
|
// so we can be sure the data lines up
|
|
if (!previous) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
seriesName: seriesName ?? 'Previous',
|
|
data: this.calculateTotalsPerTimestamp(
|
|
previous,
|
|
(_timestamp, _countArray, i) => current[i][0] * 1000
|
|
),
|
|
stack: 'previous',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aggregate all counts for each time stamp
|
|
*/
|
|
transformAggregatedTimeseries(data: EventsStatsData, seriesName: string = ''): Series {
|
|
return {
|
|
seriesName,
|
|
data: this.calculateTotalsPerTimestamp(data),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transforms query response into timeseries data to be used in a chart
|
|
*/
|
|
transformTimeseriesData(
|
|
data: EventsStatsData,
|
|
meta: EventsStats['meta'],
|
|
seriesName?: string
|
|
): Series[] {
|
|
let scale = 1;
|
|
if (seriesName) {
|
|
const unit = meta?.units?.[getAggregateAlias(seriesName)];
|
|
// Scale series values to milliseconds or bytes depending on units from meta
|
|
scale = (unit && (DURATION_UNITS[unit] ?? SIZE_UNITS[unit])) ?? 1;
|
|
}
|
|
|
|
return [
|
|
{
|
|
seriesName: seriesName || 'Current',
|
|
data: data.map(([timestamp, countsForTimestamp]) => ({
|
|
name: timestamp * 1000,
|
|
value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0) * scale,
|
|
})),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Transforms comparisonCount in query response into timeseries data to be used in a comparison chart for change alerts
|
|
*/
|
|
transformComparisonTimeseriesData(data: EventsStatsData): Series[] {
|
|
return [
|
|
{
|
|
seriesName: 'comparisonCount()',
|
|
data: data.map(([timestamp, countsForTimestamp]) => ({
|
|
name: timestamp * 1000,
|
|
value: countsForTimestamp.reduce(
|
|
(acc, {comparisonCount}) => acc + (comparisonCount ?? 0),
|
|
0
|
|
),
|
|
})),
|
|
},
|
|
];
|
|
}
|
|
|
|
processData(response: EventsStats, seriesIndex: number = 0, seriesName?: string) {
|
|
const {data, isMetricsData, totals, meta, isExtrapolatedData} = response;
|
|
const {
|
|
includeTransformedData,
|
|
includeTimeAggregation,
|
|
timeAggregationSeriesName,
|
|
currentSeriesNames,
|
|
previousSeriesNames,
|
|
comparisonDelta,
|
|
} = this.props;
|
|
const {current, previous} = this.getData(data);
|
|
const transformedData = includeTransformedData
|
|
? this.transformTimeseriesData(
|
|
current,
|
|
meta,
|
|
seriesName ?? currentSeriesNames?.[seriesIndex]
|
|
)
|
|
: [];
|
|
const transformedComparisonData =
|
|
includeTransformedData && comparisonDelta
|
|
? this.transformComparisonTimeseriesData(current)
|
|
: [];
|
|
const previousData = includeTransformedData
|
|
? this.transformPreviousPeriodData(
|
|
current,
|
|
previous,
|
|
(seriesName ? getPreviousSeriesName(seriesName) : undefined) ??
|
|
previousSeriesNames?.[seriesIndex]
|
|
)
|
|
: null;
|
|
const timeAggregatedData = includeTimeAggregation
|
|
? this.transformAggregatedTimeseries(current, timeAggregationSeriesName || '')
|
|
: {};
|
|
const timeframe =
|
|
response.start && response.end
|
|
? !previous
|
|
? {
|
|
start: response.start * 1000,
|
|
end: response.end * 1000,
|
|
}
|
|
: {
|
|
// Find the midpoint of start & end since previous includes 2x data
|
|
start: (response.start + response.end) * 500,
|
|
end: response.end * 1000,
|
|
}
|
|
: undefined;
|
|
|
|
const processedData = {
|
|
data: transformedData,
|
|
comparisonData: transformedComparisonData,
|
|
allData: data,
|
|
originalData: current,
|
|
totals,
|
|
isMetricsData,
|
|
originalPreviousData: previous,
|
|
previousData,
|
|
timeAggregatedData,
|
|
timeframe,
|
|
isExtrapolatedData,
|
|
};
|
|
|
|
return processedData;
|
|
}
|
|
|
|
render() {
|
|
const {children, showLoading, ...props} = this.props;
|
|
const {topEvents, yAxis} = this.props;
|
|
const {timeseriesData, reloading, errored, errorMessage} = this.state;
|
|
// Is "loading" if data is null
|
|
const loading = this.props.loading || timeseriesData === null;
|
|
|
|
if (showLoading && loading) {
|
|
return <LoadingPanel data-test-id="events-request-loading" />;
|
|
}
|
|
if (isMultiSeriesStats(timeseriesData, defined(topEvents))) {
|
|
// Convert multi-series results into chartable series. Multi series results
|
|
// are created when multiple yAxis are used or a topEvents request is made.
|
|
// Convert the timeseries data into a multi-series result set.
|
|
// As the server will have replied with a map like:
|
|
// {[titleString: string]: EventsStats}
|
|
let timeframe: {end: number; start: number} | undefined = undefined;
|
|
const seriesAdditionalInfo: Record<string, AdditionalSeriesInfo> = {};
|
|
const sortedTimeseriesData = Object.keys(timeseriesData)
|
|
.map(
|
|
(
|
|
seriesName: string,
|
|
index: number
|
|
): [number, Series, Series | null, AdditionalSeriesInfo] => {
|
|
const seriesData: EventsStats = timeseriesData[seriesName];
|
|
const processedData = this.processData(
|
|
seriesData,
|
|
index,
|
|
stripEquationPrefix(seriesName)
|
|
);
|
|
if (!timeframe) {
|
|
timeframe = processedData.timeframe;
|
|
}
|
|
if (processedData.isMetricsData) {
|
|
seriesAdditionalInfo[seriesName] = {
|
|
isMetricsData: processedData.isMetricsData,
|
|
};
|
|
}
|
|
return [
|
|
seriesData.order || 0,
|
|
processedData.data[0],
|
|
processedData.previousData,
|
|
{isMetricsData: processedData.isMetricsData},
|
|
];
|
|
}
|
|
)
|
|
.sort((a, b) => a[0] - b[0]);
|
|
const timeseriesResultsTypes: Record<string, AggregationOutputType> = {};
|
|
Object.keys(timeseriesData).forEach(key => {
|
|
const fieldsMeta = timeseriesData[key].meta?.fields[getAggregateAlias(key)];
|
|
if (fieldsMeta) {
|
|
timeseriesResultsTypes[key] = fieldsMeta;
|
|
}
|
|
});
|
|
const results: Series[] = sortedTimeseriesData.map(item => {
|
|
return item[1];
|
|
});
|
|
const previousTimeseriesData: Series[] | undefined = sortedTimeseriesData.some(
|
|
item => item[2] === null
|
|
)
|
|
? undefined
|
|
: sortedTimeseriesData.map(item => {
|
|
return item[2] as Series;
|
|
});
|
|
|
|
return children({
|
|
loading,
|
|
reloading,
|
|
errored,
|
|
errorMessage,
|
|
results,
|
|
timeframe,
|
|
previousTimeseriesData,
|
|
seriesAdditionalInfo,
|
|
timeseriesResultsTypes,
|
|
// sometimes we want to reference props that were given to EventsRequest
|
|
...props,
|
|
});
|
|
}
|
|
if (timeseriesData) {
|
|
const yAxisKey = yAxis && (typeof yAxis === 'string' ? yAxis : yAxis[0]);
|
|
const yAxisFieldType =
|
|
yAxisKey && timeseriesData.meta?.fields[getAggregateAlias(yAxisKey)];
|
|
const timeseriesResultsTypes = yAxisFieldType
|
|
? {[yAxisKey]: yAxisFieldType}
|
|
: undefined;
|
|
const {
|
|
data: transformedTimeseriesData,
|
|
comparisonData: transformedComparisonTimeseriesData,
|
|
allData: allTimeseriesData,
|
|
originalData: originalTimeseriesData,
|
|
totals: timeseriesTotals,
|
|
originalPreviousData: originalPreviousTimeseriesData,
|
|
previousData: previousTimeseriesData,
|
|
timeAggregatedData,
|
|
timeframe,
|
|
isMetricsData,
|
|
isExtrapolatedData,
|
|
} = this.processData(timeseriesData);
|
|
|
|
const seriesAdditionalInfo = {
|
|
[this.props.currentSeriesNames?.[0] ?? 'current']: {
|
|
isMetricsData,
|
|
isExtrapolatedData,
|
|
},
|
|
};
|
|
|
|
return children({
|
|
loading,
|
|
reloading,
|
|
errored,
|
|
errorMessage,
|
|
// meta data,
|
|
seriesAdditionalInfo,
|
|
// timeseries data
|
|
timeseriesData: transformedTimeseriesData,
|
|
comparisonTimeseriesData: transformedComparisonTimeseriesData,
|
|
allTimeseriesData,
|
|
originalTimeseriesData,
|
|
timeseriesTotals,
|
|
originalPreviousTimeseriesData,
|
|
previousTimeseriesData: previousTimeseriesData
|
|
? [previousTimeseriesData]
|
|
: previousTimeseriesData,
|
|
timeAggregatedData,
|
|
timeframe,
|
|
timeseriesResultsTypes,
|
|
// sometimes we want to reference props that were given to EventsRequest
|
|
...props,
|
|
});
|
|
}
|
|
return children({
|
|
loading,
|
|
reloading,
|
|
errored,
|
|
errorMessage,
|
|
...props,
|
|
});
|
|
}
|
|
}
|
|
export default EventsRequest;
|