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
6 changes: 6 additions & 0 deletions .changeset/stable-series-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/app': patch
---

fix: time chart series keep the same color across refreshes; series are now
ordered by name instead of by the order ClickHouse returned them
10 changes: 9 additions & 1 deletion packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -869,10 +869,18 @@ export function formatResponseForTimeChart({
}

const logLevelColorOrder = getLogLevelColorOrder();
// Series are otherwise ordered by first appearance in the response, which
// ClickHouse does not keep stable between runs. Colors are assigned by
// position, so tie-break by key to keep each series' color stable.
let sortedLineData = Object.values(lineDataMap).sort((a, b) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Colours still depend on position, so they shift whenever the set of series changes

Sorting fixes colours only when the same series come back in a different order. setLineColors (ChartUtils.tsx:637) still hands out colorIndex++ by position, and the set of series often changes when the user moves the time window, which is the repro in the PR description. Example: refresh 1 returns cart, checkout, email, so checkout gets COLORS[1]. Refresh 2 has no rows for cart, so checkout moves to COLORS[0] and email to COLORS[1]. The maxSeries cap causes the same shift when the kept top-N changes. To make a series' colour independent of what else is in the result, pick the palette index from the series key, e.g. hashCode(currentPeriodKey) from @hyperdx/common-utils/dist/core/utils modulo the palette size. Add a test where one series is missing from the second response.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

return (
logLevelColorOrder.findIndex(color => color === a.color) -
logLevelColorOrder.findIndex(color => color === b.color)
logLevelColorOrder.findIndex(color => color === b.color) ||
(a.currentPeriodKey < b.currentPeriodKey

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Comparing strings with < puts numeric group values out of order

currentPeriodKey comparison is by UTF-16 code unit, so numeric group-bys (status codes, partition ids, durations) come out as '10', '100', '9', and uppercase names sort before lowercase. The changeset says series are now 'ordered by name', and this comparison decides the series order in the chart, so the user sees this ordering. Use a.currentPeriodKey.localeCompare(b.currentPeriodKey, undefined, { numeric: true }), or an Intl.Collator with numeric: true created once outside the comparator.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

? -1
: a.currentPeriodKey > b.currentPeriodKey
? 1
: 0)
);
});

Expand Down
86 changes: 56 additions & 30 deletions packages/app/src/__tests__/ChartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ describe('ChartUtils', () => {
);
});

it('should assign the same color to a series regardless of row order', () => {
const meta = [
{ name: 'count()', type: 'UInt64' },
{ name: 'ServiceName', type: 'String' },
{ name: '__hdx_time_bucket', type: 'DateTime' },
];
const row = (ServiceName: string) => ({
'count()': 1,
ServiceName,
__hdx_time_bucket: '2025-11-26T11:12:00Z',
});
const colorsByService = (services: string[]) =>
Object.fromEntries(
formatResponseForTimeChart({
currentPeriodResponse: { data: services.map(row), meta },
dateRange: [new Date(), new Date()],
granularity: '1 minute',
generateEmptyBuckets: false,
}).lineData.map(line => [line.dataKey, line.color]),
);

const colors = colorsByService(['cart', 'checkout', 'email']);
expect(new Set(Object.values(colors)).size).toBe(3);
expect(colorsByService(['email', 'cart', 'checkout'])).toEqual(colors);
});

it('should return empty results for an empty response', () => {
const res = {
data: [],
Expand Down Expand Up @@ -252,16 +278,6 @@ describe('ChartUtils', () => {
},
{
color: COLORS[1],
dataKey: 'max · checkout',
currentPeriodKey: 'max · checkout',
previousPeriodKey: 'max · checkout (previous)',
displayName: 'max · checkout',
valueColumnName: 'max',
groupValues: ['checkout'],
isDashed: false,
},
{
color: COLORS[2],
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
currentPeriodKey:
'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
Expand All @@ -272,6 +288,16 @@ describe('ChartUtils', () => {
groupValues: ['shipping'],
isDashed: false,
},
{
color: COLORS[2],
dataKey: 'max · checkout',
currentPeriodKey: 'max · checkout',
previousPeriodKey: 'max · checkout (previous)',
displayName: 'max · checkout',
valueColumnName: 'max',
groupValues: ['checkout'],
isDashed: false,
},
{
color: COLORS[3],
dataKey: 'max · shipping',
Expand Down Expand Up @@ -334,16 +360,6 @@ describe('ChartUtils', () => {
});

expect(actual.lineData).toEqual([
{
color: SEMANTIC_INFO_HEX,
dataKey: 'info',
currentPeriodKey: 'info',
previousPeriodKey: 'info (previous)',
displayName: 'info',
valueColumnName: 'count()',
groupValues: ['info'],
isDashed: false,
},
{
color: SEMANTIC_INFO_HEX,
dataKey: 'debug',
Expand All @@ -354,6 +370,16 @@ describe('ChartUtils', () => {
groupValues: ['debug'],
isDashed: false,
},
{
color: SEMANTIC_INFO_HEX,
dataKey: 'info',
currentPeriodKey: 'info',
previousPeriodKey: 'info (previous)',
displayName: 'info',
valueColumnName: 'count()',
groupValues: ['info'],
isDashed: false,
},
{
color: SEMANTIC_ERROR_HEX,
dataKey: 'error',
Expand Down Expand Up @@ -457,16 +483,6 @@ describe('ChartUtils', () => {
},
{
color: COLORS[1],
dataKey: 'max · checkout',
currentPeriodKey: 'max · checkout',
previousPeriodKey: 'max · checkout (previous)',
displayName: 'max · checkout',
valueColumnName: 'max',
groupValues: ['checkout'],
isDashed: false,
},
{
color: COLORS[2],
dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
currentPeriodKey:
'AVG(toFloat64OrDefault(toString(Duration))) · shipping',
Expand All @@ -477,6 +493,16 @@ describe('ChartUtils', () => {
groupValues: ['shipping'],
isDashed: false,
},
{
color: COLORS[2],
dataKey: 'max · checkout',
currentPeriodKey: 'max · checkout',
previousPeriodKey: 'max · checkout (previous)',
displayName: 'max · checkout',
valueColumnName: 'max',
groupValues: ['checkout'],
isDashed: false,
},
{
color: COLORS[3],
dataKey: 'max · shipping',
Expand Down
Loading