Skip to content

Commit b5bb69e

Browse files
authored
feat: Improve pie chart implementation (#1773)
Closes HDX-3479 # Summary This PR makes a number of improvements to new pie chart implementation (#1704) 1. Pie charts are now limited to 1 series. Previously, the pie chart summed the values of each series by group, and used the sum as the slice value. This is non-obvious and probably not what users expect. With a one-series limit, this problem is eliminated. Further, the logic for formatting the pie chart data from the clickhouse response is dramatically simpler. 2. Slices are now ordered by value decreasing, to avoid randomly changing slice order on refresh 3. Instead of being randomly generated, slice colors are now consistent with the theme colors and auto-detect log and trace severity levels, matching line/bar chart behavior 4. The external dashboards API now supports reading and writing pie charts. The transformation code has been updated so that there will be a type error if any new chart types are added in the future without updating the external API code. 5. The pie chart's tooltip now matches the style of the line chart tooltip, and is updated appropriately based on the app theme and light/dark mode. 6. The chart's number format is now applied to values in the pie chart tooltip 7. Slice labels are now correctly populated when a map is accessed in the Group By (eg. when grouping by `ResourceAttributes['app']`, the slice labels include the `app` value instead of being empty). 8. Also, added some unit tests for the pie chart data transformation, and moved it to ChartUtils with the other similar chart data transformation code.
1 parent e55b81b commit b5bb69e

12 files changed

Lines changed: 592 additions & 243 deletions

File tree

‎.changeset/twenty-hats-deny.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/api": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
fix: Improve Pie Chart implemententation

‎packages/api/openapi.json‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,47 @@
11781178
}
11791179
}
11801180
},
1181+
"PieChartConfig": {
1182+
"type": "object",
1183+
"required": [
1184+
"displayType",
1185+
"sourceId",
1186+
"select"
1187+
],
1188+
"description": "Configuration for a pie chart tile. Each slice represents one group value.",
1189+
"properties": {
1190+
"displayType": {
1191+
"type": "string",
1192+
"enum": [
1193+
"pie"
1194+
],
1195+
"example": "pie"
1196+
},
1197+
"sourceId": {
1198+
"type": "string",
1199+
"description": "ID of the data source to query.",
1200+
"example": "65f5e4a3b9e77c001a111111"
1201+
},
1202+
"select": {
1203+
"type": "array",
1204+
"minItems": 1,
1205+
"maxItems": 1,
1206+
"description": "Exactly one aggregated value used to size each pie slice.",
1207+
"items": {
1208+
"$ref": "#/components/schemas/SelectItem"
1209+
}
1210+
},
1211+
"groupBy": {
1212+
"type": "string",
1213+
"maxLength": 10000,
1214+
"description": "Field expression to group results by (one slice per group value).",
1215+
"example": "service"
1216+
},
1217+
"numberFormat": {
1218+
"$ref": "#/components/schemas/NumberFormat"
1219+
}
1220+
}
1221+
},
11811222
"SearchChartConfig": {
11821223
"type": "object",
11831224
"required": [
@@ -1255,6 +1296,9 @@
12551296
{
12561297
"$ref": "#/components/schemas/NumberChartConfig"
12571298
},
1299+
{
1300+
"$ref": "#/components/schemas/PieChartConfig"
1301+
},
12581302
{
12591303
"$ref": "#/components/schemas/SearchChartConfig"
12601304
},
@@ -1269,6 +1313,7 @@
12691313
"stacked_bar": "#/components/schemas/BarChartConfig",
12701314
"table": "#/components/schemas/TableChartConfig",
12711315
"number": "#/components/schemas/NumberChartConfig",
1316+
"pie": "#/components/schemas/PieChartConfig",
12721317
"search": "#/components/schemas/SearchChartConfig",
12731318
"markdown": "#/components/schemas/MarkdownChartConfig"
12741319
}

‎packages/api/src/routers/external-api/__tests__/dashboards.test.ts‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,26 @@ describe('External API v2 Dashboards - new format', () => {
16481648
},
16491649
});
16501650

1651+
const createPieChart = (sourceId: string): ExternalDashboardTileWithId => ({
1652+
name: 'Pie Chart',
1653+
x: 6,
1654+
y: 3,
1655+
w: 3,
1656+
h: 3,
1657+
id: new ObjectId().toString(),
1658+
config: {
1659+
displayType: 'pie',
1660+
sourceId,
1661+
select: [
1662+
{
1663+
aggFn: 'count',
1664+
where: '',
1665+
},
1666+
],
1667+
groupBy: 'service.name',
1668+
},
1669+
});
1670+
16511671
const server = getServer();
16521672
let agent, team, user, traceSource, metricSource;
16531673

@@ -1912,6 +1932,7 @@ describe('External API v2 Dashboards - new format', () => {
19121932
createTableChart(traceSource._id.toString()),
19131933
createNumberChart(traceSource._id.toString()),
19141934
createMarkdownChart(),
1935+
createPieChart(traceSource._id.toString()),
19151936
],
19161937
tags: ['test', 'chart-types'],
19171938
};
@@ -1922,18 +1943,45 @@ describe('External API v2 Dashboards - new format', () => {
19221943

19231944
const { id } = response.body.data;
19241945
expect(response.body.data).toHaveProperty('id');
1925-
expect(response.body.data.tiles.length).toBe(4);
1946+
expect(response.body.data.tiles.length).toBe(5);
19261947

19271948
// Verify by retrieving the dashboard
19281949
const retrieveResponse = await authRequest('get', `${BASE_URL}/${id}`);
19291950

19301951
expect(retrieveResponse.status).toBe(200);
1931-
expect(retrieveResponse.body.data.tiles.length).toBe(4);
1952+
expect(retrieveResponse.body.data.tiles.length).toBe(5);
19321953
expect(retrieveResponse.body.data.tags).toEqual(['test', 'chart-types']);
19331954
});
19341955

19351956
it('can round-trip all supported chart types and all supported fields on each chart type', async () => {
19361957
// Arrange
1958+
const pieChart: ExternalDashboardTile = {
1959+
name: 'Pie Chart',
1960+
x: 6,
1961+
y: 3,
1962+
w: 6,
1963+
h: 3,
1964+
config: {
1965+
displayType: 'pie',
1966+
sourceId: traceSource._id.toString(),
1967+
select: [
1968+
{
1969+
aggFn: 'quantile',
1970+
level: 0.5,
1971+
valueExpression: 'Duration',
1972+
alias: 'Median Duration',
1973+
where: "env = 'production'",
1974+
whereLanguage: 'sql',
1975+
},
1976+
],
1977+
groupBy: 'service.name',
1978+
numberFormat: {
1979+
output: 'number',
1980+
mantissa: 2,
1981+
},
1982+
},
1983+
};
1984+
19371985
const lineChart: ExternalDashboardTile = {
19381986
name: 'Line Chart',
19391987
x: 0,
@@ -2087,7 +2135,14 @@ describe('External API v2 Dashboards - new format', () => {
20872135
const response = await authRequest('post', BASE_URL)
20882136
.send({
20892137
name: 'Dashboard with All Chart Types',
2090-
tiles: [lineChart, barChart, tableChart, numberChart, markdownChart],
2138+
tiles: [
2139+
lineChart,
2140+
barChart,
2141+
tableChart,
2142+
numberChart,
2143+
markdownChart,
2144+
pieChart,
2145+
],
20912146
tags: ['round-trip-test'],
20922147
})
20932148
.expect(200);
@@ -2098,6 +2153,7 @@ describe('External API v2 Dashboards - new format', () => {
20982153
expect(omit(response.body.data.tiles[2], ['id'])).toEqual(tableChart);
20992154
expect(omit(response.body.data.tiles[3], ['id'])).toEqual(numberChart);
21002155
expect(omit(response.body.data.tiles[4], ['id'])).toEqual(markdownChart);
2156+
expect(omit(response.body.data.tiles[5], ['id'])).toEqual(pieChart);
21012157
});
21022158

21032159
it('should return 400 when source IDs do not exist', async () => {

‎packages/api/src/routers/external-api/v2/dashboards.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,37 @@ async function getMissingSources(
585585
* numberFormat:
586586
* $ref: '#/components/schemas/NumberFormat'
587587
*
588+
* PieChartConfig:
589+
* type: object
590+
* required:
591+
* - displayType
592+
* - sourceId
593+
* - select
594+
* description: Configuration for a pie chart tile. Each slice represents one group value.
595+
* properties:
596+
* displayType:
597+
* type: string
598+
* enum: [pie]
599+
* example: "pie"
600+
* sourceId:
601+
* type: string
602+
* description: ID of the data source to query.
603+
* example: "65f5e4a3b9e77c001a111111"
604+
* select:
605+
* type: array
606+
* minItems: 1
607+
* maxItems: 1
608+
* description: Exactly one aggregated value used to size each pie slice.
609+
* items:
610+
* $ref: '#/components/schemas/SelectItem'
611+
* groupBy:
612+
* type: string
613+
* maxLength: 10000
614+
* description: Field expression to group results by (one slice per group value).
615+
* example: "service"
616+
* numberFormat:
617+
* $ref: '#/components/schemas/NumberFormat'
618+
*
588619
* SearchChartConfig:
589620
* type: object
590621
* required:
@@ -641,6 +672,7 @@ async function getMissingSources(
641672
* - $ref: '#/components/schemas/BarChartConfig'
642673
* - $ref: '#/components/schemas/TableChartConfig'
643674
* - $ref: '#/components/schemas/NumberChartConfig'
675+
* - $ref: '#/components/schemas/PieChartConfig'
644676
* - $ref: '#/components/schemas/SearchChartConfig'
645677
* - $ref: '#/components/schemas/MarkdownChartConfig'
646678
* discriminator:
@@ -650,6 +682,7 @@ async function getMissingSources(
650682
* stacked_bar: '#/components/schemas/BarChartConfig'
651683
* table: '#/components/schemas/TableChartConfig'
652684
* number: '#/components/schemas/NumberChartConfig'
685+
* pie: '#/components/schemas/PieChartConfig'
653686
* search: '#/components/schemas/SearchChartConfig'
654687
* markdown: '#/components/schemas/MarkdownChartConfig'
655688
*

‎packages/api/src/routers/external-api/v2/utils/dashboards.ts‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ const convertToExternalTileChartConfig = (
9191
};
9292

9393
switch (config.displayType) {
94-
case 'line':
95-
case 'stacked_bar':
94+
case DisplayType.Line:
95+
case DisplayType.StackedBar:
9696
return {
9797
displayType: config.displayType,
9898
sourceId,
@@ -106,12 +106,12 @@ const convertToExternalTileChartConfig = (
106106
select: Array.isArray(config.select)
107107
? config.select.map(convertToExternalSelectItem)
108108
: [DEFAULT_SELECT_ITEM],
109-
...(config.displayType === 'line'
109+
...(config.displayType === DisplayType.Line
110110
? { compareToPreviousPeriod: config.compareToPreviousPeriod }
111111
: {}),
112112
numberFormat: config.numberFormat,
113113
};
114-
case 'number':
114+
case DisplayType.Number:
115115
return {
116116
displayType: config.displayType,
117117
sourceId,
@@ -120,7 +120,17 @@ const convertToExternalTileChartConfig = (
120120
: [DEFAULT_SELECT_ITEM],
121121
numberFormat: config.numberFormat,
122122
};
123-
case 'table':
123+
case DisplayType.Pie:
124+
return {
125+
displayType: config.displayType,
126+
sourceId,
127+
select: Array.isArray(config.select)
128+
? [convertToExternalSelectItem(config.select[0])]
129+
: [DEFAULT_SELECT_ITEM],
130+
groupBy: stringValueOrDefault(config.groupBy, undefined),
131+
numberFormat: config.numberFormat,
132+
};
133+
case DisplayType.Table:
124134
return {
125135
...pick(config, ['having', 'numberFormat']),
126136
displayType: config.displayType,
@@ -135,25 +145,28 @@ const convertToExternalTileChartConfig = (
135145
: [DEFAULT_SELECT_ITEM],
136146
orderBy: stringValueOrDefault(config.orderBy, undefined),
137147
};
138-
case 'search':
148+
case DisplayType.Search:
139149
return {
140150
displayType: config.displayType,
141151
sourceId,
142152
select: stringValueOrDefault(config.select, ''),
143153
where: config.where,
144154
whereLanguage: config.whereLanguage ?? 'lucene',
145155
};
146-
case 'markdown':
156+
case DisplayType.Markdown:
147157
return {
148158
displayType: config.displayType,
149159
markdown: stringValueOrDefault(config.markdown, ''),
150160
};
151-
default:
161+
case DisplayType.Heatmap:
162+
case undefined:
152163
logger.error(
153164
{ config },
154-
'Error converting chart config to external chart - unrecognized display type',
165+
'Error converting chart config to external chart - unsupported display type',
155166
);
156167
return undefined;
168+
default:
169+
config.displayType satisfies never;
157170
}
158171
};
159172

@@ -257,6 +270,16 @@ export function convertToInternalTileConfig(
257270
name,
258271
};
259272
break;
273+
case 'pie':
274+
internalConfig = {
275+
...pick(externalConfig, ['groupBy', 'numberFormat']),
276+
displayType: DisplayType.Pie,
277+
select: [convertToInternalSelectItem(externalConfig.select[0])],
278+
source: externalConfig.sourceId,
279+
where: '',
280+
name,
281+
};
282+
break;
260283
case 'search':
261284
internalConfig = {
262285
...pick(externalConfig, ['select', 'where']),
@@ -276,6 +299,13 @@ export function convertToInternalTileConfig(
276299
name,
277300
};
278301
break;
302+
default:
303+
// Typecheck to ensure all display types are handled
304+
externalConfig satisfies never;
305+
306+
// We should never hit this due to the typecheck above.
307+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
308+
internalConfig = {} as SavedChartConfig;
279309
}
280310

281311
// Omit keys that are null/undefined, so that they're not saved as null in Mongo.

‎packages/api/src/utils/zod.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,14 @@ const externalDashboardNumberChartConfigSchema = z.object({
234234
numberFormat: NumberFormatSchema.optional(),
235235
});
236236

237+
const externalDashboardPieChartConfigSchema = z.object({
238+
displayType: z.literal('pie'),
239+
sourceId: objectIdSchema,
240+
select: z.array(externalDashboardSelectItemSchema).length(1),
241+
groupBy: z.string().max(10000).optional(),
242+
numberFormat: NumberFormatSchema.optional(),
243+
});
244+
237245
const externalDashboardSearchChartConfigSchema = z.object({
238246
displayType: z.literal('search'),
239247
sourceId: objectIdSchema,
@@ -253,6 +261,7 @@ export const externalDashboardTileConfigSchema = z
253261
externalDashboardBarChartConfigSchema,
254262
externalDashboardTableChartConfigSchema,
255263
externalDashboardNumberChartConfigSchema,
264+
externalDashboardPieChartConfigSchema,
256265
externalDashboardMarkdownChartConfigSchema,
257266
externalDashboardSearchChartConfigSchema,
258267
])

0 commit comments

Comments
 (0)