Alerting: Auto-format numeric values in Alert Rule History (#115708)

* Add helper function to format numeric values in alert rule history

* Use formatting function in LogRecordViewer

* Refactor numerical formatting logic

* Handle edge cases when counting decimal places

* Cleanup tests and numberFormatter code
This commit is contained in:
Rodrigo Vasconcelos de Barros
2025-12-29 10:18:42 -05:00
committed by GitHub
parent 3023a72175
commit 7182511bcf
4 changed files with 322 additions and 1 deletions
@@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => {
expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument();
expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument();
});
describe('Numeric Value Formatting', () => {
it('should format numeric values correctly in AlertInstanceValues', () => {
const records: LogRecord[] = [
{
timestamp: 1681739580000,
line: {
current: 'Alerting',
previous: 'Pending',
labels: {},
values: {
cpu_usage: 42.987654321,
memory_mb: 1234567.89,
disk_io: 0.001234,
request_count: 10000,
},
},
},
];
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
expect(screen.getByText(/cpu_usage/)).toBeInTheDocument();
expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument();
expect(screen.getByText(/memory_mb/)).toBeInTheDocument();
expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument();
expect(screen.getByText(/disk_io/)).toBeInTheDocument();
expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument();
expect(screen.getByText(/request_count/)).toBeInTheDocument();
expect(screen.getByText(/10000/)).toBeInTheDocument();
});
it('should format various numeric ranges correctly', () => {
const records: LogRecord[] = [
{
timestamp: 1681739580000,
line: {
current: 'Alerting',
previous: 'Pending',
labels: {},
values: {
small: 0.001,
normal: 42.5,
large: 123456,
boundary_low: 0.01,
boundary_high: 10000,
},
},
},
];
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
expect(screen.getByText(/small/)).toBeInTheDocument();
expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument();
expect(screen.getByText(/normal/)).toBeInTheDocument();
expect(screen.getByText(/42\.5/)).toBeInTheDocument();
expect(screen.getByText(/large/)).toBeInTheDocument();
expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument();
expect(screen.getByText(/boundary_low/)).toBeInTheDocument();
expect(screen.getByText(/0\.01/)).toBeInTheDocument();
expect(screen.getByText(/boundary_high/)).toBeInTheDocument();
expect(screen.getByText(/10000/)).toBeInTheDocument();
});
});
});
@@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag';
import { ErrorMessageRow } from './ErrorMessageRow';
import { LogRecord, omitLabels } from './common';
import { formatNumericValue } from './numberFormatter';
type LogRecordViewerProps = {
records: LogRecord[];
@@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record<string, number> }
return (
<>
{values.map(([key, value]) => (
<AlertLabel key={key} labelKey={key} value={String(value)} />
<AlertLabel key={key} labelKey={key} value={formatNumericValue(value)} />
))}
</>
);
@@ -0,0 +1,173 @@
import { formatNumericValue } from './numberFormatter';
describe('formatNumericValue', () => {
describe('Zero and special values', () => {
it('should format zero correctly', () => {
expect(formatNumericValue(0)).toBe('0');
expect(formatNumericValue(-0)).toBe('0');
});
it('should handle NaN', () => {
expect(formatNumericValue(NaN)).toBe('NaN');
});
it('should handle Infinity', () => {
expect(formatNumericValue(Infinity)).toBe('Infinity');
expect(formatNumericValue(-Infinity)).toBe('-Infinity');
});
});
describe('Very small numbers (scientific notation)', () => {
it('should use scientific notation for values less than 1e-2', () => {
const result1 = formatNumericValue(1e-3);
expect(result1).toMatch(/^1\.000e-3$/i);
const result2 = formatNumericValue(0.001);
expect(result2).toMatch(/^1\.000e-3$/i);
const result3 = formatNumericValue(0.009);
expect(result3).toMatch(/^9\.000e-3$/i);
});
it('should use scientific notation for values just below 1e-2', () => {
const result = formatNumericValue(0.00999);
expect(result).toMatch(/^9\.990e-3$/i);
});
it('should format the example from requirements correctly', () => {
// 1.4153928131348452 has > 4 decimal places, so should use scientific notation
const result = formatNumericValue(1.4153928131348452);
expect(result).toMatch(/^1\.415e\+0$/i);
});
it('should handle negative very small numbers', () => {
const result = formatNumericValue(-1e-3);
expect(result).toMatch(/^-1\.000e-3$/i);
const result2 = formatNumericValue(-0.001);
expect(result2).toMatch(/^-1\.000e-3$/i);
});
});
describe('Human-readable range (standard notation)', () => {
it('should use standard notation for boundary value 1e-2', () => {
expect(formatNumericValue(0.01)).toBe('0.01');
});
it('should use standard notation for values in readable range', () => {
expect(formatNumericValue(0.1)).toBe('0.1');
expect(formatNumericValue(1)).toBe('1');
expect(formatNumericValue(1.234)).toBe('1.234');
expect(formatNumericValue(42.5)).toBe('42.5');
});
it('should limit to 4 decimal places without rounding integer parts', () => {
expect(formatNumericValue(123.456)).toBe('123.456');
expect(formatNumericValue(1234.567)).toBe('1234.567');
expect(formatNumericValue(9999.9)).toBe('9999.9');
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
});
it('should use scientific notation for numbers with more than 4 decimal places', () => {
// Numbers with > 4 decimals should use scientific notation even in readable range
const result1 = formatNumericValue(123.456789);
expect(result1).toMatch(/^1\.235e\+2$/i);
const result2 = formatNumericValue(1.23456789);
expect(result2).toMatch(/^1\.235e\+0$/i);
const result3 = formatNumericValue(42.987654321);
expect(result3).toMatch(/^4\.299e\+1$/i);
});
it('should use standard notation for boundary value 1e4', () => {
expect(formatNumericValue(10000)).toBe('10000');
});
it('should handle negative numbers in readable range', () => {
expect(formatNumericValue(-0.1)).toBe('-0.1');
expect(formatNumericValue(-123.456)).toBe('-123.456');
expect(formatNumericValue(-9999.9)).toBe('-9999.9');
});
it('should use scientific notation for negative numbers with excessive precision', () => {
const result = formatNumericValue(-42.987654321);
expect(result).toMatch(/^-4\.299e\+1$/i);
});
});
describe('Very large numbers (scientific notation)', () => {
it('should use scientific notation for values greater than 1e4', () => {
const result1 = formatNumericValue(10001);
expect(result1).toMatch(/^1\.000e\+4$/i);
const result2 = formatNumericValue(123456);
expect(result2).toMatch(/^1\.235e\+5$/i);
});
it('should handle negative very large numbers', () => {
const result = formatNumericValue(-1e5);
expect(result).toMatch(/^-1\.000e\+5$/i);
const result2 = formatNumericValue(-123456);
expect(result2).toMatch(/^-1\.235e\+5$/i);
});
});
describe('Edge cases', () => {
it('should handle numbers exactly at boundaries', () => {
expect(formatNumericValue(0.01)).toBe('0.01');
const justBelow = formatNumericValue(0.009999);
expect(justBelow).toMatch(/^9\.999e-3$/i);
expect(formatNumericValue(10000)).toBe('10000');
const justAbove = formatNumericValue(10001);
expect(justAbove).toMatch(/^1\.000e\+4$/i);
});
it('should use scientific notation for very precise decimals with > 4 decimal places', () => {
expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i);
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i);
});
it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => {
expect(formatNumericValue(1.2345)).toBe('1.2345');
expect(formatNumericValue(0.1234)).toBe('0.1234');
expect(formatNumericValue(123.4567)).toBe('123.4567');
});
});
describe('countDecimalPlaces edge cases', () => {
it('should handle numbers that toString() would convert to scientific notation', () => {
const result = formatNumericValue(1e-10);
expect(result).toMatch(/^1\.000e-10$/i);
const result2 = formatNumericValue(1e10);
expect(result2).toMatch(/^1\.000e\+10$/i);
});
it('should correctly count decimals for numbers with trailing zeros', () => {
expect(formatNumericValue(1.234)).toBe('1.234');
expect(formatNumericValue(1.2)).toBe('1.2');
expect(formatNumericValue(1.0)).toBe('1');
});
it('should handle boundary values correctly', () => {
expect(formatNumericValue(0.01)).toBe('0.01');
expect(formatNumericValue(10000)).toBe('10000');
expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i);
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i);
});
it('should handle numbers in readable range that have many decimals', () => {
expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i);
expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i);
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
});
});
});
@@ -0,0 +1,75 @@
const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2;
const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4;
const MAX_DECIMAL_PLACES = 4;
const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals
const readableRangeFormatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: MAX_DECIMAL_PLACES,
useGrouping: false,
});
/**
* Counts the number of decimal places in a number.
* Only processes numbers in readable range (1e-2 to 1e4) to avoid
* toString() scientific notation issues for very large/small numbers.
*
* Uses toFixed(10) to ensure standard notation representation.
* 10 decimal places is sufficient to detect if a number has > 4 decimal places.
*/
function countDecimalPlaces(value: number): number {
if (Number.isInteger(value)) {
return 0;
}
const absValue = Math.abs(value);
// Only count decimals for numbers in readable range
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
return 0;
}
const str = value.toFixed(10);
const decimalIndex = str.indexOf('.');
if (decimalIndex === -1) {
return 0;
}
// Count decimal places, removing trailing zeros
const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, '');
return decimalPart.length;
}
/**
* Formats a numeric value for display in alert rule history.
* - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places
* - For very small values (< 1e-2): uses scientific notation with 4 significant digits
* - For very large values (> 1e4): uses scientific notation with 4 significant digits
* - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits
*
* @param value - The number to format
* @returns A formatted string representation of the number
*/
export function formatNumericValue(value: number): string {
if (!Number.isFinite(value)) {
return String(value);
}
if (value === 0) {
return '0';
}
const absValue = Math.abs(value);
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
return value.toExponential(EXPONENTIAL_DECIMALS);
}
const decimalPlaces = countDecimalPlaces(value);
if (decimalPlaces > MAX_DECIMAL_PLACES) {
return value.toExponential(EXPONENTIAL_DECIMALS);
}
return readableRangeFormatter.format(value);
}