Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-metrics-trace-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---

Show RED metrics (Throughput, Errors, Duration) above the trace search results instead of the single count histogram. The three charts share a synced hover cursor, Errors toggles between rate and volume, and a RED/Heatmap switch flips the area to the duration heatmap. Logs and other sources keep the existing histogram.
90 changes: 70 additions & 20 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
Group,
Modal,
Paper,
SegmentedControl,
Select,
Stack,
Text,
Expand Down Expand Up @@ -137,6 +138,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
import PatternTable from './components/PatternTable';
import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel';
import { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart';
import SourceSchemaPreview, {
isSourceSchemaPreviewEnabled,
} from './components/SourceSchemaPreview';
Expand Down Expand Up @@ -1059,6 +1061,12 @@ export function DBSearchPage() {
]).withDefault('results'),
);

// RED metrics vs heatmap for the trace results chart area. Owned here so the
// switch can live inline in the search stats row instead of a dedicated row.
const [traceChartMode, setTraceChartMode] = useState<'red' | 'heatmap'>(
'red',
);

const [patternColumn, setPatternColumn] = useQueryState(
'patternColumn',
parseAsString,
Expand Down Expand Up @@ -2556,6 +2564,23 @@ export function DBSearchPage() {
enableParallelQueries
/>
<Group gap="sm" align="center">
{searchedSource != null &&
isTraceSource(searchedSource) &&
searchedSource.durationExpression && (
<SegmentedControl
size="xs"
value={traceChartMode}
onChange={v =>
setTraceChartMode(
v === 'heatmap' ? 'heatmap' : 'red',
)
}
data={[
{ label: 'RED', value: 'red' },
{ label: 'Heatmap', value: 'heatmap' },
]}
/>
)}
{shouldShowLiveModeHint &&
denoiseResults != true && (
<ResumeLiveTailButton
Expand All @@ -2576,26 +2601,51 @@ export function DBSearchPage() {
</Group>
</Group>
</Box>
{!hasQueryError && (
<Box
className={searchPageStyles.timeChartContainer}
mih="0"
>
<DBTimeChart
sourceId={searchedConfig.source ?? undefined}
showLegend={false}
config={histogramTimeChartConfig}
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
onFocusSeries={handleFocusSeries}
enableParallelQueries
/>
</Box>
)}
{!hasQueryError &&
(searchedSource != null &&
isTraceSource(searchedSource) &&
searchedSource.durationExpression ? (
<Box
className={searchPageStyles.timeChartContainer}
mih="0"
style={{ height: 240 }}
>
<TraceRedMetricsChart
mode={traceChartMode}
histogramTimeChartConfig={
histogramTimeChartConfig
}
heatmapChartConfig={{
...chartConfig,
dateRange: searchedTimeRange,
with: aliasWith,
}}
source={searchedSource}
isReady={isReady}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
/>
</Box>
) : (
<Box
className={searchPageStyles.timeChartContainer}
mih="0"
>
<DBTimeChart
sourceId={searchedConfig.source ?? undefined}
showLegend={false}
config={histogramTimeChartConfig}
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
onFocusSeries={handleFocusSeries}
enableParallelQueries
/>
</Box>
))}
</>
)}
{hasQueryError && queryError ? (
Expand Down
66 changes: 64 additions & 2 deletions packages/app/src/HDXMultiSeriesTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import {
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Text,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { AxisDomain } from 'recharts/types/util/types';
import { AxisDomain, XAxisTickContentProps } from 'recharts/types/util/types';
import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import { Button, Popover, Tooltip as MantineTooltip } from '@mantine/core';
Expand Down Expand Up @@ -650,6 +651,8 @@ export const MemoChart = memo(function MemoChart({
granularity,
dateRangeEndInclusive = true,
fitYAxisToData = false,
compactXAxisLabels = false,
yAxisMaxDomain,
}: {
graphResults: any[];
setIsClickActive: (v: ActiveClickPayload | undefined) => void;
Expand Down Expand Up @@ -683,6 +686,19 @@ export const MemoChart = memo(function MemoChart({
* (with padding) instead of zero.
**/
fitYAxisToData?: boolean;
/**
* When true, anchor the first x-axis label to the start and the last to the
* end (instead of centering every label) so edge labels are not clipped on
* narrow charts, e.g. the side-by-side RED metrics tiles.
*/
compactXAxisLabels?: boolean;
/**
* Cap the y-axis upper bound at this value (e.g. 1 for a 0-100% rate). The
* axis still auto-scales below the cap so small values keep a tight range,
* and a flat/zero series falls back to the cap instead of a degenerate
* auto-domain. Only applied on the default (non-fit, non-selection) path.
*/
yAxisMaxDomain?: number;
}) {
const _id = useId();
const id = _id.replace(/:/g, '');
Expand Down Expand Up @@ -799,6 +815,18 @@ export const MemoChart = memo(function MemoChart({
// fit the lower bound to the data. When neither applies, let Recharts
// auto-calculate the upper bound while pinning the lower bound to zero.
if (!hasSelection && !shouldFitYAxis) {
if (yAxisMaxDomain != null) {
// Auto-scale up to the data max (with headroom) but never above the
// cap; a flat or zero series uses the cap instead of a degenerate
// auto-domain (which recharts renders as e.g. 0-400% for a 0% rate).
return [
0,
(dataMax: number) =>
Number.isFinite(dataMax) && dataMax > 0
? Math.min(dataMax * 1.1, yAxisMaxDomain)
: yAxisMaxDomain,
];
}
return [0, 'auto'];
}

Expand Down Expand Up @@ -843,6 +871,7 @@ export const MemoChart = memo(function MemoChart({
selectedSeriesNames,
fitYAxisToData,
displayType,
yAxisMaxDomain,
]);

const [containerWidth, setContainerWidth] = useState(0);
Expand Down Expand Up @@ -919,6 +948,35 @@ export const MemoChart = memo(function MemoChart({
[formatTime],
);

// Compact mode: anchor the first label to the start and the last to the end
// so neither is clipped on a narrow chart. Renders every tick through one
// path (token color, mono) so the axis stays visually consistent.
const renderCompactXTick = useCallback(
({ x, y, payload, index, visibleTicksCount }: XAxisTickContentProps) => {
const textAnchor =
index <= 0
? 'start'
: index >= visibleTicksCount - 1
? 'end'
: 'middle';
return (
<Text
x={x}
y={y}
dy={8}
textAnchor={textAnchor}
verticalAnchor="start"
fontSize={11}
fontFamily="IBM Plex Mono, monospace"
fill="var(--mantine-color-dimmed)"
>
{xTickFormatter(Number(payload.value), index)}
</Text>
);
},
[xTickFormatter],
);

const tickFormatter = useCallback(
(value: number) => {
return axisNumberFormat
Expand Down Expand Up @@ -1265,7 +1323,11 @@ export const MemoChart = memo(function MemoChart({
type="number"
tickFormatter={xTickFormatter}
minTickGap={100}
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
tick={
compactXAxisLabels
? renderCompactXTick
: { fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }
}
/>
<YAxis
width={Y_AXIS_WIDTH}
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/components/DBTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ type DBTimeChartComponentProps = {
* behavior), which is all a standalone chart can do.
*/
onFocusSeries?: (filters: SeriesGroupFilter[]) => void;
/**
* Anchor the first/last x-axis labels inward so they are not clipped on
* narrow charts (e.g. side-by-side RED metric tiles). Forwarded to the chart.
*/
compactXAxisLabels?: boolean;
/**
* Cap the y-axis upper bound (e.g. 1 for a 0-100% rate) while still
* auto-scaling below it. Forwarded to the chart.
*/
yAxisMaxDomain?: number;
};

function DBTimeChartComponent({
Expand All @@ -327,6 +337,8 @@ function DBTimeChartComponent({
showDateRangeIndicator = true,
errorVariant,
onFocusSeries,
compactXAxisLabels,
yAxisMaxDomain,
}: DBTimeChartComponentProps) {
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
new Set(),
Expand Down Expand Up @@ -848,6 +860,8 @@ function DBTimeChartComponent({
granularity={granularity}
dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive}
fitYAxisToData={queriedConfig.fitYAxisToData}
compactXAxisLabels={compactXAxisLabels}
yAxisMaxDomain={yAxisMaxDomain}
/>
</>
)}
Expand Down
Loading
Loading