Skip to content

Commit

Permalink
feat(dashboards): Add incomplete bucket support to LineChartWidget (#…
Browse files Browse the repository at this point in the history
…81405)

Shamelessly adapted from
#72469, with some minor changes
to the code. In short, given a data delay of about 90s, any chart
buckets that fall into that delay are plotted with a dotted line.

Right now this is done using a series of crimes, in which we split the
series into two: a complete and an incomplete. A future version of
ECharts promised to allow styling line segments of a series, but right
now that's not possible. Even using a custom renderer doesn't work
because in that case the segments are plotted one-by-one, which creates
a "broken" line, without proper caps.
  • Loading branch information
gggritso authored Nov 28, 2024
1 parent 28e09bd commit 6a1fd3b
Show file tree
Hide file tree
Showing 8 changed files with 427 additions and 16 deletions.
2 changes: 1 addition & 1 deletion static/app/views/dashboards/widgets/common/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type Meta = {
type TableRow = Record<string, number | string | undefined>;
export type TableData = TableRow[];

type TimeSeriesItem = {
export type TimeSeriesItem = {
timestamp: string;
value: number;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import {LineChartWidget} from './lineChartWidget';
import sampleDurationTimeSeries from './sampleDurationTimeSeries.json';

describe('LineChartWidget', () => {
describe('Layout', () => {
Expand All @@ -9,7 +10,7 @@ describe('LineChartWidget', () => {
<LineChartWidget
title="eps()"
description="Number of events per second"
timeseries={[]}
timeseries={[sampleDurationTimeSeries]}
meta={{
fields: {
'eps()': 'rate',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {TimeseriesData} from '../common/types';
import {LineChartWidget} from './lineChartWidget';
import sampleDurationTimeSeries from './sampleDurationTimeSeries.json';
import sampleThroughputTimeSeries from './sampleThroughputTimeSeries.json';
import {shiftTimeserieToNow} from './shiftTimeserieToNow';

const sampleDurationTimeSeries2 = {
...sampleDurationTimeSeries,
Expand Down Expand Up @@ -74,6 +75,13 @@ export default storyBook(LineChartWidget, story => {
UTC or not
</p>

<p>
The <code>dataCompletenessDelay</code> prop indicates that this data is live,
and the last few buckets might not have complete data. The delay is a number in
seconds. Any data bucket that happens in that delay window will be plotted with
a dotted line. By default the delay is <code>0</code>
</p>

<SideBySide>
<MediumWidget>
<LineChartWidget
Expand All @@ -94,7 +102,11 @@ export default storyBook(LineChartWidget, story => {
<MediumWidget>
<LineChartWidget
title="span.duration"
timeseries={[durationTimeSeries1, durationTimeSeries2]}
dataCompletenessDelay={60 * 60 * 3}
timeseries={[
shiftTimeserieToNow(durationTimeSeries1),
shiftTimeserieToNow(durationTimeSeries2),
]}
utc
meta={{
fields: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function LineChartWidget(props: Props) {
timeseries={timeseries}
utc={props.utc}
meta={props.meta}
dataCompletenessDelay={props.dataCompletenessDelay}
/>
</LineChartWrapper>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,48 +1,148 @@
import {useRef} from 'react';
import type {
TooltipFormatterCallback,
TopLevelFormatterParams,
} from 'echarts/types/dist/shared';

import BaseChart from 'sentry/components/charts/baseChart';
import {getFormatter} from 'sentry/components/charts/components/tooltip';
import LineSeries from 'sentry/components/charts/series/lineSeries';
import {useChartZoom} from 'sentry/components/charts/useChartZoom';
import {isChartHovered} from 'sentry/components/charts/utils';
import type {ReactEchartsRef} from 'sentry/types/echarts';

import type {Meta, TimeseriesData} from '../common/types';

import {formatChartValue} from './formatChartValue';
import {splitSeriesIntoCompleteAndIncomplete} from './splitSeriesIntoCompleteAndIncomplete';

export interface LineChartWidgetVisualizationProps {
timeseries: TimeseriesData[];
dataCompletenessDelay?: number;
meta?: Meta;
utc?: boolean;
}

export function LineChartWidgetVisualization(props: LineChartWidgetVisualizationProps) {
const {timeseries, meta} = props;
const chartRef = useRef<ReactEchartsRef>(null);
const {meta} = props;

const dataCompletenessDelay = props.dataCompletenessDelay ?? 0;

const chartZoomProps = useChartZoom({
saveOnZoom: true,
});

let completeSeries: TimeseriesData[] = props.timeseries;
const incompleteSeries: TimeseriesData[] = [];

if (dataCompletenessDelay > 0) {
completeSeries = [];

props.timeseries.forEach(timeserie => {
const [completeSerie, incompleteSerie] = splitSeriesIntoCompleteAndIncomplete(
timeserie,
dataCompletenessDelay
);

if (completeSerie && completeSerie.data.length > 0) {
completeSeries.push(completeSerie);
}

if (incompleteSerie && incompleteSerie.data.length > 0) {
incompleteSeries.push(incompleteSerie);
}
});
}

// TODO: There's a TypeScript indexing error here. This _could_ in theory be
// `undefined`. We need to guard against this in the parent component, and
// show an error.
const firstSeries = props.timeseries[0];

// TODO: Raise error if attempting to plot series of different types or units
const firstSeriesField = timeseries[0]?.field;
const firstSeriesField = firstSeries?.field;
const type = meta?.fields?.[firstSeriesField] ?? 'number';
const unit = meta?.units?.[firstSeriesField] ?? undefined;

const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
params,
asyncTicket
) => {
// Only show the tooltip of the current chart. Otherwise, all tooltips
// in the chart group appear.
if (!isChartHovered(chartRef?.current)) {
return '';
}

let deDupedParams = params;

if (Array.isArray(params)) {
// We split each series into a complete and incomplete series, and they
// have the same name. The two series overlap at one point on the chart,
// to create a continuous line. This code prevents both series from
// showing up on the tooltip
const uniqueSeries = new Set<string>();

deDupedParams = params.filter(param => {
// Filter null values from tooltip
if (param.value[1] === null) {
return false;
}

if (uniqueSeries.has(param.seriesName)) {
return false;
}

uniqueSeries.add(param.seriesName);
return true;
});
}

return getFormatter({
isGroupedByDate: true,
showTimeInTooltip: true,
truncate: true,
utc: props.utc ?? false,
})(deDupedParams, asyncTicket);
};

return (
<BaseChart
series={timeseries.map(timeserie => {
return LineSeries({
name: timeserie.field,
color: timeserie.color,
animation: false,
data: timeserie.data.map(datum => {
return [datum.timestamp, datum.value];
}),
});
})}
ref={chartRef}
series={[
...completeSeries.map(timeserie => {
return LineSeries({
name: timeserie.field,
color: timeserie.color,
animation: false,
data: timeserie.data.map(datum => {
return [datum.timestamp, datum.value];
}),
});
}),
...incompleteSeries.map(timeserie => {
return LineSeries({
name: timeserie.field,
color: timeserie.color,
animation: false,
data: timeserie.data.map(datum => {
return [datum.timestamp, datum.value];
}),
lineStyle: {
type: 'dotted',
},
silent: true,
});
}),
]}
utc={props.utc}
legend={{
top: 0,
left: 0,
}}
showTimeInTooltip
tooltip={{
formatter,
valueFormatter: value => {
return formatChartValue(value, type, unit);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type {TimeseriesData} from '../common/types';

export function shiftTimeserieToNow(timeserie: TimeseriesData): TimeseriesData {
const currentTimestamp = new Date().getTime();

const lastDatum = timeserie.data.at(-1);
if (!lastDatum) {
return timeserie;
}

const lastTimestampInTimeserie = new Date(lastDatum.timestamp).getTime();
const diff = currentTimestamp - lastTimestampInTimeserie;

return {
...timeserie,
data: timeserie.data.map(datum => ({
...datum,
timestamp: new Date(new Date(datum.timestamp).getTime() + diff).toISOString(),
})),
};
}
Loading

0 comments on commit 6a1fd3b

Please sign in to comment.