diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx
index a90e9dc52a8..cbc5563538f 100644
--- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx
+++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+ });
+ });
});
diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx
index c1d90347c74..06fcde4a1ae 100644
--- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx
+++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx
@@ -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 }
return (
<>
{values.map(([key, value]) => (
-
+
))}
>
);
diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts
new file mode 100644
index 00000000000..77dfe40df5a
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts
new file mode 100644
index 00000000000..8e518c2c932
--- /dev/null
+++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts
@@ -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);
+}