Skip to content

Commit e838436

Browse files
improve alert precision to match the threshold value (#1387)
Users reported that the precision was way off to what the threshold value was, this helps ensure the two numbers have the same precision. Before: <img width="1280" height="363" alt="image" src="https://github.com/user-attachments/assets/fc1bc72c-a70e-4068-aa06-3a01d6c65b2b" /> After: <img width="1446" height="618" alt="Screenshot 2025-11-19 at 4 20 38 PM" src="https://github.com/user-attachments/assets/49be78eb-dac9-49f4-b490-a354fb69fb71" /> **Note:** One thing that could be better is if we instead used the Number Format specified on the frontend, this would require us to move the Numbro dependency and logic into common-utils, and we would also probably want to update the alert value UI to also use numbro.. I can take a stab at this if we think it's better. I figured this was a good interim solution. Fixes HDX-2847
1 parent 562dd7e commit e838436

File tree

3 files changed

+183
-5
lines changed

3 files changed

+183
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/api": patch
3+
---
4+
5+
Improve value rounding on alerts to match thresholds

packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
AlertMessageTemplateDefaultView,
4343
buildAlertMessageTemplateHdxLink,
4444
buildAlertMessageTemplateTitle,
45+
formatValueToMatchThreshold,
4546
getDefaultExternalAction,
4647
isAlertResolved,
4748
renderAlertTemplate,
@@ -225,6 +226,93 @@ describe('checkAlerts', () => {
225226
);
226227
});
227228

229+
it('formatValueToMatchThreshold', () => {
230+
// Test with integer threshold - value should be formatted as integer
231+
expect(formatValueToMatchThreshold(1111.11111111, 1)).toBe('1111');
232+
expect(formatValueToMatchThreshold(5, 1)).toBe('5');
233+
expect(formatValueToMatchThreshold(5.9, 1)).toBe('6');
234+
235+
// Test scientific notation threshold - value should be formatted as integer
236+
expect(formatValueToMatchThreshold(0.00001, 0.0000001)).toBe('0.0000100');
237+
238+
// Test with single decimal threshold - value should have 1 decimal place
239+
expect(formatValueToMatchThreshold(1111.11111111, 1.5)).toBe('1111.1');
240+
expect(formatValueToMatchThreshold(5.555, 1.5)).toBe('5.6');
241+
242+
// Test with multiple decimal places in threshold
243+
expect(formatValueToMatchThreshold(1.1234, 0.1234)).toBe('1.1234');
244+
expect(formatValueToMatchThreshold(5.123456789, 0.1234)).toBe('5.1235');
245+
expect(formatValueToMatchThreshold(10, 0.12)).toBe('10.00');
246+
247+
// Test with very long decimal threshold
248+
expect(formatValueToMatchThreshold(1111.11111111, 0.123456)).toBe(
249+
'1111.111111',
250+
);
251+
252+
// Test edge cases
253+
expect(formatValueToMatchThreshold(0, 1)).toBe('0');
254+
expect(formatValueToMatchThreshold(0.5, 1)).toBe('1');
255+
expect(formatValueToMatchThreshold(0.123456, 0.1234)).toBe('0.1235');
256+
257+
// Test negative values
258+
expect(formatValueToMatchThreshold(-5.555, 1.5)).toBe('-5.6');
259+
expect(formatValueToMatchThreshold(-1111.11111111, 1)).toBe('-1111');
260+
261+
// Test when value is already an integer and threshold is integer
262+
expect(formatValueToMatchThreshold(100, 50)).toBe('100');
263+
expect(formatValueToMatchThreshold(0, 0)).toBe('0');
264+
265+
// Test rounding behavior
266+
expect(formatValueToMatchThreshold(1.5, 0.1)).toBe('1.5');
267+
expect(formatValueToMatchThreshold(1.55, 0.1)).toBe('1.6');
268+
expect(formatValueToMatchThreshold(1.449, 0.1)).toBe('1.4');
269+
270+
// Test very large numbers (main benefit of NumberFormat over toFixed)
271+
expect(formatValueToMatchThreshold(9999999999999.5, 1)).toBe(
272+
'10000000000000',
273+
);
274+
expect(formatValueToMatchThreshold(1234567890123.456, 0.1)).toBe(
275+
'1234567890123.5',
276+
);
277+
expect(formatValueToMatchThreshold(999999999999999, 1)).toBe(
278+
'999999999999999',
279+
);
280+
281+
// Test that thousand separators are NOT added
282+
expect(formatValueToMatchThreshold(123456.789, 1)).toBe('123457');
283+
expect(formatValueToMatchThreshold(1000000.5, 0.1)).toBe('1000000.5');
284+
285+
// Test precision at JavaScript's safe integer boundary
286+
expect(formatValueToMatchThreshold(9007199254740991, 1)).toBe(
287+
'9007199254740991',
288+
);
289+
290+
// Test very small numbers in different notations
291+
expect(formatValueToMatchThreshold(0.000000001, 0.0000000001)).toBe(
292+
'0.0000000010',
293+
);
294+
expect(formatValueToMatchThreshold(1.23e-8, 1e-9)).toBe('0.000000012');
295+
296+
// Test mixed magnitude (large value with small precision threshold)
297+
expect(formatValueToMatchThreshold(1000000.123456, 0.0001)).toBe(
298+
'1000000.1235',
299+
);
300+
expect(formatValueToMatchThreshold(99999.999999, 0.01)).toBe('100000.00');
301+
302+
// Test threshold with trailing zeros vs without
303+
expect(formatValueToMatchThreshold(5.5, 1.0)).toBe('6'); // 1.0 should be treated as integer
304+
expect(formatValueToMatchThreshold(5.55, 0.1)).toBe('5.6'); // 0.10 has 1 decimal place
305+
306+
// Test edge case: very small threshold, large value
307+
expect(formatValueToMatchThreshold(1234567.89, 0.000001)).toBe(
308+
'1234567.890000',
309+
);
310+
311+
// Test rounding at different magnitudes
312+
expect(formatValueToMatchThreshold(999.9999, 0.001)).toBe('1000.000');
313+
expect(formatValueToMatchThreshold(0.9999, 0.001)).toBe('1.000');
314+
});
315+
228316
it('buildAlertMessageTemplateTitle', () => {
229317
expect(
230318
buildAlertMessageTemplateTitle({
@@ -280,6 +368,62 @@ describe('checkAlerts', () => {
280368
);
281369
});
282370

371+
it('buildAlertMessageTemplateTitle formats value to match threshold precision', () => {
372+
// Test with decimal threshold - value should be formatted to match
373+
const decimalChartView: AlertMessageTemplateDefaultView = {
374+
...defaultChartView,
375+
alert: {
376+
...defaultChartView.alert,
377+
threshold: 1.5,
378+
},
379+
value: 1111.11111111,
380+
};
381+
382+
expect(
383+
buildAlertMessageTemplateTitle({
384+
view: decimalChartView,
385+
}),
386+
).toMatchInlineSnapshot(
387+
`"🚨 Alert for \\"Test Chart\\" in \\"My Dashboard\\" - 1111.1 exceeds 1.5"`,
388+
);
389+
390+
// Test with multiple decimal places
391+
const multiDecimalChartView: AlertMessageTemplateDefaultView = {
392+
...defaultChartView,
393+
alert: {
394+
...defaultChartView.alert,
395+
threshold: 0.1234,
396+
},
397+
value: 1.123456789,
398+
};
399+
400+
expect(
401+
buildAlertMessageTemplateTitle({
402+
view: multiDecimalChartView,
403+
}),
404+
).toMatchInlineSnapshot(
405+
`"🚨 Alert for \\"Test Chart\\" in \\"My Dashboard\\" - 1.1235 exceeds 0.1234"`,
406+
);
407+
408+
// Test with integer value and decimal threshold
409+
const integerValueView: AlertMessageTemplateDefaultView = {
410+
...defaultChartView,
411+
alert: {
412+
...defaultChartView.alert,
413+
threshold: 0.12,
414+
},
415+
value: 10,
416+
};
417+
418+
expect(
419+
buildAlertMessageTemplateTitle({
420+
view: integerValueView,
421+
}),
422+
).toMatchInlineSnapshot(
423+
`"🚨 Alert for \\"Test Chart\\" in \\"My Dashboard\\" - 10.00 exceeds 0.12"`,
424+
);
425+
});
426+
283427
it('isAlertResolved', () => {
284428
// Test OK state returns true
285429
expect(isAlertResolved(AlertState.OK)).toBe(true);
@@ -2199,14 +2343,14 @@ describe('checkAlerts', () => {
21992343
1,
22002344
'https://hooks.slack.com/services/123',
22012345
{
2202-
text: '🚨 Alert for "CPU" in "My Dashboard" - 6.25 exceeds 1',
2346+
text: '🚨 Alert for "CPU" in "My Dashboard" - 6 exceeds 1',
22032347
blocks: [
22042348
{
22052349
text: {
22062350
text: [
2207-
`*<http://app:8080/dashboards/${dashboard._id}?from=1700170200000&granularity=5+minute&to=1700174700000 | 🚨 Alert for "CPU" in "My Dashboard" - 6.25 exceeds 1>*`,
2351+
`*<http://app:8080/dashboards/${dashboard._id}?from=1700170200000&granularity=5+minute&to=1700174700000 | 🚨 Alert for "CPU" in "My Dashboard" - 6 exceeds 1>*`,
22082352
'',
2209-
'6.25 exceeds 1',
2353+
'6 exceeds 1',
22102354
'Time Range (UTC): [Nov 16 10:05:00 PM - Nov 16 10:10:00 PM)',
22112355
'',
22122356
].join('\n'),

packages/api/src/tasks/checkAlerts/template.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,33 @@ export const isAlertResolved = (state?: AlertState): boolean => {
8787
return state === AlertState.OK;
8888
};
8989

90+
/**
91+
* Formats the value to match the decimal precision of the threshold.
92+
* This ensures consistent display of numbers in alert messages.
93+
* Uses Intl.NumberFormat for better precision handling with large numbers.
94+
*/
95+
export const formatValueToMatchThreshold = (
96+
value: number,
97+
threshold: number,
98+
): string => {
99+
// Format threshold with NumberFormat to get its string representation
100+
const thresholdFormatted = new Intl.NumberFormat('en-US', {
101+
maximumSignificantDigits: 21,
102+
useGrouping: false,
103+
}).format(threshold);
104+
105+
// Count decimal places in the formatted threshold
106+
const decimalIndex = thresholdFormatted.indexOf('.');
107+
const decimalPlaces =
108+
decimalIndex === -1 ? 0 : thresholdFormatted.length - decimalIndex - 1;
109+
110+
return new Intl.NumberFormat('en-US', {
111+
minimumFractionDigits: decimalPlaces,
112+
maximumFractionDigits: decimalPlaces,
113+
useGrouping: false,
114+
}).format(value);
115+
};
116+
90117
export const notifyChannel = async ({
91118
channel,
92119
message,
@@ -339,9 +366,10 @@ export const buildAlertMessageTemplateTitle = ({
339366
`Tile with id ${alert.tileId} not found in dashboard ${dashboard.name}`,
340367
);
341368
}
369+
const formattedValue = formatValueToMatchThreshold(value, alert.threshold);
342370
const baseTitle = template
343371
? handlebars.compile(template)(view)
344-
: `Alert for "${tile.config.name}" in "${dashboard.name}" - ${value} ${
372+
: `Alert for "${tile.config.name}" in "${dashboard.name}" - ${formattedValue} ${
345373
doesExceedThreshold(alert.thresholdType, alert.threshold, value)
346374
? alert.thresholdType === AlertThresholdType.ABOVE
347375
? 'exceeds'
@@ -614,8 +642,9 @@ ${truncatedResults}
614642
if (dashboard == null) {
615643
throw new Error(`Source is ${alert.source} but dashboard is null`);
616644
}
645+
const formattedValue = formatValueToMatchThreshold(value, alert.threshold);
617646
rawTemplateBody = `${group ? `Group: "${group}"` : ''}
618-
${value} ${
647+
${formattedValue} ${
619648
doesExceedThreshold(alert.thresholdType, alert.threshold, value)
620649
? alert.thresholdType === AlertThresholdType.ABOVE
621650
? 'exceeds'

0 commit comments

Comments
 (0)