Skip to content

Commit 9cb2b00

Browse files
authored
Fix handling of special characters in categorical values (#757)
This PR addresses a bug that occurred when categorical values contained special characters such as colons. Previously, entities were stored as a comma-separated name-value pair string in the heatmap cell's custom data field. When a cell was clicked, the custom data was retrieved and the string was split via colons. This caused errors if the categorical value itself contained colons or if multiple name-value pairs included commas. To fix this issue, this PR changes the storage format to a JSON string in the cell's custom data. When a cell is clicked, the JSON object is restored. Since JSON naturally handles special characters, this eliminates the need for additional special character handling. Additionally, the previous implementation stored a newline-separated name-value pair in the y-axis and a comma-separated name-value pair in the cell tooltip. With entities now stored as JSON in custom data, we no longer have the flexibility to store these different formats. This PR standardizes the format to newline-separated name-value pairs for both the y-axis and cell tooltip. Testing Done: * Conducted end-to-end testing in preview, historical, and real-time use cases. * Added unit tests. Signed-off-by: Kaituo Li <kaituo@amazon.com>
1 parent e509a03 commit 9cb2b00

File tree

4 files changed

+160
-22
lines changed

4 files changed

+160
-22
lines changed

public/pages/AnomalyCharts/utils/__tests__/anomalyChartUtils.test.ts

+90
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
getAnomalySummary,
1616
convertAlerts,
1717
generateAlertAnnotations,
18+
buildHeatmapPlotData,
19+
ANOMALY_HEATMAP_COLORSCALE,
1820
} from '../anomalyChartUtils';
1921
import { httpClientMock, coreServicesMock } from '../../../../../test/mocks';
2022
import { MonitorAlert } from '../../../../models/interfaces';
@@ -158,3 +160,91 @@ describe('anomalyChartUtils function tests', () => {
158160
]);
159161
});
160162
});
163+
164+
165+
describe('buildHeatmapPlotData', () => {
166+
it('should build the heatmap plot data correctly', () => {
167+
const x = ['05-13 06:58:52 2024'];
168+
const y = ['Exception while fetching data\napp_2'];
169+
const z = [0.1];
170+
const anomalyOccurrences = [1];
171+
const entityLists = [
172+
[
173+
{ name: 'error', value: 'Exception while fetching data' },
174+
{ name: 'service', value: 'app_2' },
175+
],
176+
];
177+
const cellTimeInterval = 10;
178+
179+
const expected = {
180+
x: x,
181+
y: y,
182+
z: z,
183+
colorscale: ANOMALY_HEATMAP_COLORSCALE,
184+
zmin: 0,
185+
zmax: 1,
186+
type: 'heatmap',
187+
showscale: false,
188+
xgap: 2,
189+
ygap: 2,
190+
opacity: 1,
191+
text: anomalyOccurrences,
192+
customdata: entityLists,
193+
hovertemplate:
194+
'<b>Entities</b>: %{y}<br>' +
195+
'<b>Time</b>: %{x}<br>' +
196+
'<b>Max anomaly grade</b>: %{z}<br>' +
197+
'<b>Anomaly occurrences</b>: %{text}' +
198+
'<extra></extra>',
199+
cellTimeInterval: cellTimeInterval,
200+
};
201+
202+
const result = buildHeatmapPlotData(x, y, z, anomalyOccurrences, entityLists, cellTimeInterval);
203+
expect(result).toEqual(expected);
204+
});
205+
206+
it('should handle multiple entries correctly', () => {
207+
const x = ['05-13 06:58:52 2024', '05-13 07:58:52 2024'];
208+
const y = ['Exception while fetching data\napp_2', 'Network error\napp_3'];
209+
const z = [0.1, 0.2];
210+
const anomalyOccurrences = [1, 2];
211+
const entityLists = [
212+
[
213+
{ name: 'error', value: 'Exception while fetching data' },
214+
{ name: 'service', value: 'app_2' },
215+
],
216+
[
217+
{ name: 'error', value: 'Network error' },
218+
{ name: 'service', value: 'app_3' },
219+
],
220+
];
221+
const cellTimeInterval = 10;
222+
223+
const expected = {
224+
x: x,
225+
y: y,
226+
z: z,
227+
colorscale: ANOMALY_HEATMAP_COLORSCALE,
228+
zmin: 0,
229+
zmax: 1,
230+
type: 'heatmap',
231+
showscale: false,
232+
xgap: 2,
233+
ygap: 2,
234+
opacity: 1,
235+
text: anomalyOccurrences,
236+
customdata: entityLists,
237+
hovertemplate:
238+
'<b>Entities</b>: %{y}<br>' +
239+
'<b>Time</b>: %{x}<br>' +
240+
'<b>Max anomaly grade</b>: %{z}<br>' +
241+
'<b>Anomaly occurrences</b>: %{text}' +
242+
'<extra></extra>',
243+
cellTimeInterval: cellTimeInterval,
244+
};
245+
246+
const result = buildHeatmapPlotData(x, y, z, anomalyOccurrences, entityLists, cellTimeInterval);
247+
expect(result).toEqual(expected);
248+
});
249+
});
250+

public/pages/AnomalyCharts/utils/anomalyChartUtils.tsx

+14-2
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,19 @@ export const getSampleAnomaliesHeatmapData = (
310310
return [resultPlotData];
311311
};
312312

313-
const buildHeatmapPlotData = (
313+
314+
/**
315+
* Builds the data for a heatmap plot representing anomalies.
316+
*
317+
* @param {any[]} x - The x coordinate value for the cell representing time.
318+
* @param {any[]} y - Array of newline-separated name-value pairs representing entities. This is used for the y-axis labels and displayed in the mouse hover tooltip.
319+
* @param {any[]} z - Array representing the maximum anomaly grades.
320+
* @param {any[]} anomalyOccurrences - Array representing the number of anomalies.
321+
* @param {any[]} entityLists - JSON representation of name-value pairs. Note that the values may contain special characters such as commas and newlines. JSON is used here because it naturally handles special characters and nested structures.
322+
* @param {number} cellTimeInterval - The interval covered by each heatmap cell.
323+
* @returns {PlotData} - The data structure required for plotting the heatmap.
324+
*/
325+
export const buildHeatmapPlotData = (
314326
x: any[],
315327
y: any[],
316328
z: any[],
@@ -334,7 +346,7 @@ const buildHeatmapPlotData = (
334346
text: anomalyOccurrences,
335347
customdata: entityLists,
336348
hovertemplate:
337-
'<b>Entities</b>: %{customdata}<br>' +
349+
'<b>Entities</b>: %{y}<br>' +
338350
'<b>Time</b>: %{x}<br>' +
339351
'<b>Max anomaly grade</b>: %{z}<br>' +
340352
'<b>Anomaly occurrences</b>: %{text}' +

public/pages/utils/__tests__/anomalyResultUtils.test.ts

+54
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
getFeatureDataPointsForDetector,
1515
parsePureAnomalies,
1616
buildParamsForGetAnomalyResultsWithDateRange,
17+
transformEntityListsForHeatmap,
18+
convertHeatmapCellEntityStringToEntityList,
1719
} from '../anomalyResultUtils';
1820
import { getRandomDetector } from '../../../redux/reducers/__tests__/utils';
1921
import {
@@ -25,6 +27,8 @@ import {
2527
import { ANOMALY_RESULT_SUMMARY, PARSED_ANOMALIES } from './constants';
2628
import { MAX_ANOMALIES } from '../../../utils/constants';
2729
import { SORT_DIRECTION, AD_DOC_FIELDS } from '../../../../server/utils/constants';
30+
import { Entity } from '../../../../server/models/interfaces';
31+
import { NUM_CELLS } from '../../AnomalyCharts/utils/anomalyChartUtils'
2832

2933
describe('anomalyResultUtils', () => {
3034
let randomDetector_20_min: Detector;
@@ -636,4 +640,54 @@ describe('anomalyResultUtils', () => {
636640
expect(parsedPureAnomalies).toStrictEqual(PARSED_ANOMALIES);
637641
});
638642
});
643+
644+
describe('transformEntityListsForHeatmap', () => {
645+
it('should transform an empty entityLists array to an empty array', () => {
646+
const entityLists: Entity[][] = [];
647+
const result = transformEntityListsForHeatmap(entityLists);
648+
expect(result).toEqual([]);
649+
const convertedBack = convertHeatmapCellEntityStringToEntityList("[]");
650+
expect([]).toEqual(convertedBack);
651+
});
652+
653+
it('should transform a single entity list correctly', () => {
654+
const entityLists: Entity[][] = [
655+
[
656+
{ name: 'entity1', value: 'value1' },
657+
{ name: 'entity2', value: 'value2' },
658+
],
659+
];
660+
661+
const json = JSON.stringify(entityLists[0]);
662+
663+
const expected = [
664+
new Array(NUM_CELLS).fill(json),
665+
];
666+
667+
const result = transformEntityListsForHeatmap(entityLists);
668+
expect(result).toEqual(expected);
669+
const convertedBack = convertHeatmapCellEntityStringToEntityList(json);
670+
expect(entityLists[0]).toEqual(convertedBack);
671+
});
672+
673+
it('should handle special characters in entity values', () => {
674+
const entityLists: Entity[][] = [
675+
[
676+
{ name: 'entity1', value: 'value1, with comma' },
677+
{ name: 'entity2', value: 'value2\nwith newline' },
678+
],
679+
];
680+
681+
const json = JSON.stringify(entityLists[0]);
682+
683+
const expected = [
684+
new Array(NUM_CELLS).fill(json),
685+
];
686+
687+
const result = transformEntityListsForHeatmap(entityLists);
688+
expect(result).toEqual(expected);
689+
const convertedBack = convertHeatmapCellEntityStringToEntityList(json);
690+
expect(entityLists[0]).toEqual(convertedBack);
691+
});
692+
});
639693
});

public/pages/utils/anomalyResultUtils.ts

+2-20
Original file line numberDiff line numberDiff line change
@@ -1820,22 +1820,7 @@ export const convertToCategoryFieldAndEntityString = (
18201820
export const convertHeatmapCellEntityStringToEntityList = (
18211821
heatmapCellEntityString: string
18221822
) => {
1823-
let entityList = [] as Entity[];
1824-
const entitiesAsStringList = heatmapCellEntityString.split(
1825-
HEATMAP_CELL_ENTITY_DELIMITER
1826-
);
1827-
var i;
1828-
for (i = 0; i < entitiesAsStringList.length; i++) {
1829-
const entityAsString = entitiesAsStringList[i];
1830-
const entityAsFieldValuePair = entityAsString.split(
1831-
HEATMAP_CALL_ENTITY_KEY_VALUE_DELIMITER
1832-
);
1833-
entityList.push({
1834-
name: entityAsFieldValuePair[0],
1835-
value: entityAsFieldValuePair[1],
1836-
});
1837-
}
1838-
return entityList;
1823+
return JSON.parse(heatmapCellEntityString);
18391824
};
18401825

18411826
export const entityListsMatch = (
@@ -1895,10 +1880,7 @@ const appendEntityFilters = (requestBody: any, entityList: Entity[]) => {
18951880
export const transformEntityListsForHeatmap = (entityLists: any[]) => {
18961881
let transformedEntityLists = [] as any[];
18971882
entityLists.forEach((entityList: Entity[]) => {
1898-
const listAsString = convertToCategoryFieldAndEntityString(
1899-
entityList,
1900-
', '
1901-
);
1883+
const listAsString = JSON.stringify(entityList);
19021884
let row = [];
19031885
var i;
19041886
for (i = 0; i < NUM_CELLS; i++) {

0 commit comments

Comments
 (0)