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
14 changes: 14 additions & 0 deletions .changeset/series-limit-ranking-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
'@hyperdx/api': patch
---

feat(charts): choose the range the Series Limit ranks over

A time chart with a Series Limit picks its top N series from the most recent
part of the time range, so a series with no recent events was dropped even when
it was the largest overall. Display Settings now has a "Rank series over"
option: "Most recent window" (the default, unchanged behaviour) or "Full range".
The option is also available as `seriesLimitRankingRange` in the external API
and MCP, and the Series Limit descriptions now say how the ranking works.
24 changes: 22 additions & 2 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1845,9 +1845,19 @@
"seriesLimit": {
"type": "integer",
"minimum": 0,
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.\n",
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series. The range the ranking covers is set by seriesLimitRankingRange.\n",
"example": 5
},
"seriesLimitRankingRange": {
"type": "string",
"enum": [
"recent",
"full"
],
"description": "Range the top-N ranking for seriesLimit covers when the chart is fetched in chunks. \"recent\" ranks over the newest chunk window only, so a series with no events in that window is dropped. \"full\" ranks over the whole time range, which is accurate but scans more data. Ignored without a positive seriesLimit and a groupBy.\n",
"default": "recent",
"example": "full"
},
"formulas": {
"type": "array",
"maxItems": 10,
Expand Down Expand Up @@ -1922,9 +1932,19 @@
"seriesLimit": {
"type": "integer",
"minimum": 0,
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.",
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series. The range the ranking covers is set by seriesLimitRankingRange.",
"example": 5
},
"seriesLimitRankingRange": {
"type": "string",
"enum": [
"recent",
"full"
],
"description": "Range the top-N ranking for seriesLimit covers when the chart is fetched in chunks. \"recent\" ranks over the newest chunk window only, so a series with no events in that window is dropped. \"full\" ranks over the whole time range, which is accurate but scans more data. Ignored without a positive seriesLimit and a groupBy.\n",
"default": "recent",
"example": "full"
},
"formulas": {
"type": "array",
"maxItems": 10,
Expand Down
22 changes: 18 additions & 4 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
MetricsDataType,
NumberTileColorConditionSchema,
SearchConditionTrimmedLanguageSchema,
SeriesLimitRankingRangeSchema,
StaticListDashboardFilterSchema,
} from '@hyperdx/common-utils/dist/types';
import { z } from 'zod';
Expand Down Expand Up @@ -103,13 +104,24 @@ const sqlOnlyVariableMacroDescription =

const timeChartSeriesLimitDescription =
'Maximum number of series to fetch (the "Series Limit" display setting). ' +
'Keeps the top-N groups by aggregated value over the queried range and ' +
'drops the rest. Requires `groupBy`; ignored on a chart without one. ' +
'Three-state: omit to apply the default render cap, 0 for unlimited, or a ' +
'positive N to keep the top N.';
'Keeps the top-N groups by aggregated value and drops the rest. The ' +
'ranking covers the range set by `seriesLimitRankingRange`, which defaults ' +
'to the most recent part of the queried range. Requires `groupBy`; ignored ' +
'on a chart without one. Three-state: omit to apply the default render ' +
'cap, 0 for unlimited, or a positive N to keep the top N.';

const seriesLimitSchema = z.number().int().nonnegative().optional();

const seriesLimitRankingRangeSchema =
SeriesLimitRankingRangeSchema.optional().describe(
'Range the top-N ranking for `seriesLimit` covers (the "Rank series ' +
'over" display setting). "recent" (default) ranks over the newest ' +
'chunk of the queried range only, so a group with no recent events is ' +
'dropped even if it is the largest overall. "full" ranks over the ' +
'whole queried range, which is accurate but scans more data. Ignored ' +
'without a positive `seriesLimit` and a `groupBy`.',
);

const numberTileColorDescription =
'Static color for the displayed number, as a palette token such as ' +
'"chart-blue", "chart-green", or "chart-success" (see the enum for the ' +
Expand Down Expand Up @@ -705,6 +717,7 @@ export const mcpLineTileSchema = mcpTileLayoutSchema.extend({
'Scale the y-axis to the data range instead of starting at zero.',
),
seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription),
seriesLimitRankingRange: seriesLimitRankingRangeSchema,
formulas: mcpTileFormulasSchema,
showOperandSeries: mcpShowOperandSeriesSchema,
}),
Expand All @@ -726,6 +739,7 @@ export const mcpBarTileSchema = mcpTileLayoutSchema.extend({
.optional()
.describe(tileLevelNumberFormatDescription),
seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription),
seriesLimitRankingRange: seriesLimitRankingRangeSchema,
formulas: mcpTileFormulasSchema,
showOperandSeries: mcpShowOperandSeriesSchema,
}),
Expand Down
30 changes: 28 additions & 2 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,8 +703,21 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* description: >
* Maximum number of series rendered (top-N by value). Omit to use
* the default render cap, set 0 for unlimited, or a positive N to
* keep the top N series.
* keep the top N series. The range the ranking covers is set by
* seriesLimitRankingRange.
* example: 5
* seriesLimitRankingRange:
* type: string
* enum: [recent, full]
* description: >
* Range the top-N ranking for seriesLimit covers when the chart is
* fetched in chunks. "recent" ranks over the newest chunk window
* only, so a series with no events in that window is dropped.
* "full" ranks over the whole time range, which is accurate but
* scans more data. Ignored without a positive seriesLimit and a
* groupBy.
* default: recent
* example: full
* formulas:
* type: array
* maxItems: 10
Expand Down Expand Up @@ -773,8 +786,21 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* description: >-
* Maximum number of series rendered (top-N by value). Omit to use
* the default render cap, set 0 for unlimited, or a positive N to
* keep the top N series.
* keep the top N series. The range the ranking covers is set by
* seriesLimitRankingRange.
* example: 5
* seriesLimitRankingRange:
* type: string
* enum: [recent, full]
* description: >
* Range the top-N ranking for seriesLimit covers when the chart is
* fetched in chunks. "recent" ranks over the newest chunk window
* only, so a series with no events in that window is dropped.
* "full" ranks over the whole time range, which is accurate but
* scans more data. Ignored without a positive seriesLimit and a
* groupBy.
* default: recent
* example: full
* formulas:
* type: array
* maxItems: 10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,131 @@ describe('convertToExternalDashboard orphan-ref heal', () => {
});
},
);

// seriesLimitRankingRange defaults to 'recent' when absent. An explicit
// value must survive a GET->PUT round-trip; null maps to absent.
describe.each([DisplayType.Line, DisplayType.StackedBar])(
'seriesLimitRankingRange serialization for %s tiles',
displayType => {
function readRankingRange(
seriesLimitRankingRange: 'recent' | 'full' | null | undefined,
): unknown {
const doc = makeDoc({
tiles: [
makeTile({
id: 'ranking-range-tile',
config: {
displayType,
source: new mongoose.Types.ObjectId().toString(),
name: 'Ranking range tile',
select: [{ aggFn: 'count', valueExpression: '' }],
where: '',
groupBy: 'ServiceName',
seriesLimit: 5,
seriesLimitRankingRange,
},
}),
],
});
const wire = JSON.parse(
JSON.stringify(convertToExternalDashboard(doc)),
);
return wire.tiles[0].config.seriesLimitRankingRange;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
}

it('passes full through unchanged', () => {
expect(readRankingRange('full')).toBe('full');
});

it('passes an explicit recent through unchanged', () => {
expect(readRankingRange('recent')).toBe('recent');
});

it('emits it as absent when stored as null or not set', () => {
expect(readRankingRange(null)).toBeUndefined();
expect(readRankingRange(undefined)).toBeUndefined();
});

// The PUT side: what convertToInternalTileConfig stores for the value
// a client sends. Without this, a save that drops the field would pass.
function writeRankingRange(
seriesLimitRankingRange: 'recent' | 'full' | undefined,
): unknown {
const shared = {
sourceId: new mongoose.Types.ObjectId().toString(),
select: [{ aggFn: 'count' as const, where: '' }],
groupBy: 'ServiceName',
seriesLimit: 5,
seriesLimitRankingRange,
};
const internal = convertToInternalTileConfig({
id: 'ranking-range-tile',
x: 0,
y: 0,
w: 12,
h: 4,
name: 'Ranking range tile',
config:
displayType === DisplayType.StackedBar
? { ...shared, displayType: 'stacked_bar' as const }
: { ...shared, displayType: 'line' as const },
});
if (!isBuilderSavedChartConfig(internal.config)) {
throw new Error('Expected a builder config for a time chart tile');
}
return internal.config.seriesLimitRankingRange;
}

it('stores full when a client sends it', () => {
expect(writeRankingRange('full')).toBe('full');
});

it('stores an explicit recent when a client sends it', () => {
expect(writeRankingRange('recent')).toBe('recent');
});

it('stores nothing when a client omits it', () => {
expect(writeRankingRange(undefined)).toBeUndefined();
});

it.each(['recent', 'full'] as const)(
'keeps %s through GET then PUT',
value => {
const doc = makeDoc({
tiles: [
makeTile({
id: 'ranking-range-tile',
config: {
displayType,
source: new mongoose.Types.ObjectId().toString(),
name: 'Ranking range tile',
select: [{ aggFn: 'count', valueExpression: '' }],
where: '',
groupBy: 'ServiceName',
seriesLimit: 5,
seriesLimitRankingRange: value,
},
}),
],
});
const [externalTile] = convertToExternalDashboard(doc).tiles;
const externalConfig = externalTile.config;
if (!externalConfig) {
throw new Error('Expected the tile to have an external config');
}
const internal = convertToInternalTileConfig({
...externalTile,
config: externalConfig,
});
if (!isBuilderSavedChartConfig(internal.config)) {
throw new Error('Expected a builder config for a time chart tile');
}
expect(internal.config.seriesLimitRankingRange).toBe(value);
expect(internal.config.seriesLimit).toBe(5);
},
);
},
);
});

describe('convertToExternalDashboard stale aggregation params', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export const EVALUATION_INERT_CONFIG_KEYS: ReadonlySet<string> = new Set([
// Evaluation-relevant only with a groupBy, which (on variants whose shape
// lacks seriesLimit, i.e. number) is itself refused when present.
'seriesLimit',
// Only read by the chunked time-chart fetch in the app; the alert task
// does not chunk its queries.
'seriesLimitRankingRange',
// The alert task derives granularity from alert.interval
// (tasks/checkAlerts/index.ts: `${windowSizeInMins} minute`) and the alert
// detail chart from intervalToGranularity, so the stored value only affects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ export const convertToExternalTileChartConfig = (
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
seriesLimit: config.seriesLimit ?? undefined,
// null/undefined map to absent (the default, 'recent').
seriesLimitRankingRange: config.seriesLimitRankingRange ?? undefined,
...externalFormulaFields,
...externalShowOperandSeriesField,
};
Expand All @@ -360,6 +362,8 @@ export const convertToExternalTileChartConfig = (
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
seriesLimit: config.seriesLimit ?? undefined,
// null/undefined map to absent (the default, 'recent').
seriesLimitRankingRange: config.seriesLimitRankingRange ?? undefined,
...externalFormulaFields,
...externalShowOperandSeriesField,
};
Expand Down Expand Up @@ -775,6 +779,7 @@ export function convertToInternalTileConfig(
fillNulls: externalConfig.fillNulls === false ? false : undefined,
seriesReturnType: externalConfig.asRatio ? 'ratio' : undefined,
seriesLimit: externalConfig.seriesLimit,
seriesLimitRankingRange: externalConfig.seriesLimitRankingRange,
name,
} satisfies BuilderSavedChartConfig;
break;
Expand Down
7 changes: 7 additions & 0 deletions packages/api/src/utils/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
QueryExpressionDashboardFilterSchema,
scheduleStartAtSchema,
SearchConditionLanguageSchema as whereLanguageSchema,
SeriesLimitRankingRangeSchema,
StaticListDashboardFilterSchema,
tagsSchema,
validateAlertChannelSelection,
Expand Down Expand Up @@ -354,6 +355,9 @@ const externalDashboardLineChartConfigSchema =
// Three-state, matching the internal SharedChartSettingsSchema.seriesLimit:
// omitted = default render cap, 0 = unlimited, positive N = top-N by peak.
seriesLimit: z.number().int().nonnegative().optional(),
// Matches the internal SelectSQLStatementSchema.seriesLimitRankingRange:
// omitted = 'recent' (rank over the newest chunk window).
seriesLimitRankingRange: SeriesLimitRankingRangeSchema.optional(),
});

const externalDashboardLineRawSqlChartConfigSchema =
Expand All @@ -376,6 +380,9 @@ const externalDashboardBarChartConfigSchema =
// Three-state, matching the internal SharedChartSettingsSchema.seriesLimit:
// omitted = default render cap, 0 = unlimited, positive N = top-N by peak.
seriesLimit: z.number().int().nonnegative().optional(),
// Matches the internal SelectSQLStatementSchema.seriesLimitRankingRange:
// omitted = 'recent' (rank over the newest chunk window).
seriesLimitRankingRange: SeriesLimitRankingRangeSchema.optional(),
});

const externalDashboardBarRawSqlChartConfigSchema =
Expand Down
Loading
Loading