Skip to content
Open
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 clicking a status series on the volume chart filters the results. Logs and other sources keep the existing histogram.
32 changes: 26 additions & 6 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
BuilderSavedChartConfig,
ChartConfigWithDateRange,
ChartConfigWithOptDateRange,
ChartPaletteToken,
DateRange,
DisplayType,
Filter,
Expand Down Expand Up @@ -62,7 +63,12 @@ import {
import { getMetricNameSql } from './otelSemanticConventions';
import { AggFn, TableChartSeries, TimeChartSeries } from './types';
import { NumberFormat } from './types';
import { getColorProps, getLogLevelColorOrder, logLevelColor } from './utils';
import {
getColorFromCSSToken,
getColorProps,
getLogLevelColorOrder,
logLevelColor,
} from './utils';

type SortOrder = 'asc' | 'desc';

Expand Down Expand Up @@ -636,6 +642,7 @@ interface LineDataWithOptionalColor extends Omit<LineData, 'color'> {

function setLineColors(
sortedLineData: LineDataWithOptionalColor[],
seriesColors?: Record<string, ChartPaletteToken>,
Comment thread
alex-fedotyev marked this conversation as resolved.
Resolved
): LineData[] {
// Ensure that the current and previous period lines are the same color
const lineColorByCurrentPeriodKey = new Map<string, string>();
Expand All @@ -646,10 +653,15 @@ function setLineColors(
if (lineColorByCurrentPeriodKey.has(currentPeriodKey)) {
line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey);
} else if (!line.color) {
line.color = getColorProps(
colorIndex++,
line.displayName ?? line.dataKey,
);
const seriesName = line.displayName ?? line.dataKey;
// A color set on the series' select item (keyed by its result-column name)
// wins over the positional palette, so a series keeps the same hue no
// matter how the data sorts it. A pinned series does not consume a
// positional slot, so unpinned series keep their natural palette order.
const pinnedToken = seriesColors?.[line.valueColumnName];
line.color = pinnedToken
? getColorFromCSSToken(pinnedToken)
: getColorProps(colorIndex++, seriesName);
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
} else {
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
Expand Down Expand Up @@ -801,6 +813,7 @@ export function formatResponseForTimeChart({
hiddenSeries = [],
previousPeriodOffsetSeconds = 0,
maxSeries = MAX_RENDERED_TIME_CHART_SERIES,
seriesColors,
}: {
dateRange: [Date, Date];
granularity?: SQLInterval;
Expand All @@ -816,6 +829,13 @@ export function formatResponseForTimeChart({
* every series (the "load all" escape hatch behind the hidden-series notice).
*/
maxSeries?: number;
/**
* Pin specific series (keyed by result-column name) to a palette token instead
* of the positional palette, so their color is stable across sessions and can
* avoid hues reserved for other meanings (e.g. keeping latency off error red).
* Callers derive this from each select item's `color`.
*/
seriesColors?: Record<string, ChartPaletteToken>;
}) {
const meta = currentPeriodResponse.meta;

Expand Down Expand Up @@ -1050,7 +1070,7 @@ export function formatResponseForTimeChart({
(a, b) => a[timestampColumn.name] - b[timestampColumn.name],
);

const sortedLineDataWithColors = setLineColors(sortedLineData);
const sortedLineDataWithColors = setLineColors(sortedLineData, seriesColors);

// Count of LOGICAL groups actually rendered, in the same unit as
// hiddenSeriesCount — so a comparison chart (current + previous entries per
Expand Down
115 changes: 85 additions & 30 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,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 @@ -220,6 +221,13 @@ const SearchConfigSchema = z.object({
type SearchConfigFromSchema = z.infer<typeof SearchConfigSchema>;

const QUERY_KEY_PREFIX = 'search';
// The RED tiles are auxiliary viz, not the results query, so they key their
// queries under a separate prefix to stay out of the `search executed`
// telemetry (which matches [QUERY_KEY_PREFIX] only). Otherwise the slow
// avg/p95/p99 duration query would become what the reported search latency
// measures. The live-tail pause, on the other hand, does wait on this prefix
// (see the pause signal below) so live ticks don't stack fresh quantile scans.
const RED_QUERY_KEY_PREFIX = `${QUERY_KEY_PREFIX}-red`;

// Clicks inside the results panel keep the row side panel open (so users can
// scroll the table or select a different row); clicks anywhere else on the page
Expand Down Expand Up @@ -1710,6 +1718,16 @@ function DBSearchPageContent() {
queryKey: [QUERY_KEY_PREFIX],
}) > 0;

// Live tail also waits on the RED metric queries. They live under a separate
// prefix (kept out of the search-latency telemetry above), so without this
// each ~10s tick would fire a fresh avg/p95/p99 scan without waiting for the
// previous one, stacking slow quantile queries while live tail runs. Resolves
// to 0 when the RED tiles aren't mounted, so it's a no-op off the trace view.
const isRedQueryFetching =
useIsFetching({
queryKey: [RED_QUERY_KEY_PREFIX],
}) > 0;

const { searchElapsedMs } = useSearchTelemetry({
isAnyQueryFetching,
isLive: isLive ?? false,
Expand Down Expand Up @@ -1766,9 +1784,12 @@ function DBSearchPageContent() {
onTimeRangeSelect,
// The save modal captures the search as it stands, so refreshing the time
// range underneath it is both surprising and a source of query-string
// writes that would race the navigation to the new saved search.
// writes that would race the navigation to the new saved search. Live tail
// also waits on the RED metric queries (isRedQueryFetching) so ticks don't
// stack fresh quantile scans while a previous one is still in flight.
pause:
isAnyQueryFetching ||
isRedQueryFetching ||
!queryReady ||
!isTabVisible ||
saveSearchModalState != null,
Expand Down Expand Up @@ -1995,6 +2016,17 @@ function DBSearchPageContent() {

const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]);

// The trace results chart shows RED metrics only for a trace source that
// exposes a duration column; otherwise the single histogram stays. Derived
// once, as the narrowed source or null, so the callers can't drift apart on
// which one renders.
const traceRedMetricsSource =
searchedSource != null &&
isTraceSource(searchedSource) &&
searchedSource.durationExpression
? searchedSource
: null;

const histogramTimeChartConfig = useMemo(() => {
if (chartConfig == null) {
return undefined;
Expand Down Expand Up @@ -2711,11 +2743,7 @@ function DBSearchPageContent() {
setAnalysisMode={setAnalysisMode}
chartConfig={filtersChartConfig}
sourceId={inputSourceObj?.id}
showDelta={
!!(searchedSource?.kind === SourceKind.Trace
? searchedSource.durationExpression
: undefined)
}
showDelta={traceRedMetricsSource != null}
onColumnToggle={toggleColumn}
displayedColumns={displayedColumns}
onCollapse={isCompact ? undefined : collapseFilters}
Expand Down Expand Up @@ -2872,30 +2900,57 @@ function DBSearchPageContent() {
</Group>
</Group>
</Box>
{!hasQueryError && (
<Box
className={
isCompact
? `${searchPageStyles.timeChartContainer} ${searchPageStyles.timeChartContainerCompact}`
: searchPageStyles.timeChartContainer
}
mih="0"
>
<DBTimeChart
sourceId={searchedConfig.source ?? undefined}
showLegend={isCustomGroupBy}
config={histogramTimeChartConfig}
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
onFocusSeries={handleFocusSeries}
enableParallelQueries
/>
</Box>
)}
{!hasQueryError &&
// The three side-by-side RED tiles need room, so on a
// compact (narrow/mobile) viewport fall back to the
// single histogram. The RED tiles also have fixed
// grouping (Throughput by status, Errors by status,
// Duration ungrouped), so when the user picks a custom
// group-by fall back to the histogram, which honors it.
(traceRedMetricsSource != null &&
!isCompact &&
!isCustomGroupBy ? (
<Box
className={searchPageStyles.timeChartContainer}
mih="0"
h={240}
Comment thread
alex-fedotyev marked this conversation as resolved.
Resolved
>
<TraceRedMetricsChart
Comment thread
alex-fedotyev marked this conversation as resolved.
Resolved
histogramTimeChartConfig={
histogramTimeChartConfig
}
source={traceRedMetricsSource}
isReady={isReady}
queryKeyPrefix={RED_QUERY_KEY_PREFIX}
throughputQueryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
onFocusSeries={handleFocusSeries}
/>
</Box>
) : (
<Box
className={
isCompact
? `${searchPageStyles.timeChartContainer} ${searchPageStyles.timeChartContainerCompact}`
: searchPageStyles.timeChartContainer
}
mih="0"
>
<DBTimeChart
sourceId={searchedConfig.source ?? undefined}
showLegend={isCustomGroupBy}
config={histogramTimeChartConfig}
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
onFocusSeries={handleFocusSeries}
enableParallelQueries
/>
</Box>
))}
</>
)}
{hasQueryError && queryError ? (
Expand Down
Loading
Loading