Skip to content

Commit 056bddf

Browse files
lunalzmopensearch-changeset-bot[bot]ruanylTackAdam
authored
[Explore Vis] fix field formatter not applied to explore query result (#10574)
Currently, time fields in the result set may appear as plain strings (e.g., "2025-09-24 14:50:00") without timezone information. As a result, visualizations always display them in UTC, and the times do not adjust according to the system timezone settings (e.g., UTC+8). Root Cause In Explore, the PPL query results were fetched without passing the language-level field formatter to searchSource.fetch. Without this formatter, date values such as "2025-09-24 13:50:46.476" remain as raw UTC strings and are not normalized to ISO8601 (YYYY-MM-DDTHH:mm:ss.SSSZ), preventing timezone conversion. Additionally, the logic for determining whether a field should be treated as a date field was unreliable — it inferred types based on the field name (e.g., checking if the name included "date" or "timestamp"), which caused incorrect or inconsistent behavior. Additional Note Vega visualizations internally use d3-time and d3-time-format to handle time scales and formatting. These libraries only support local time and UTC modes — arbitrary timezones are not supported. Therefore, visualization time display is now set to local time by default (the browser’s timezone). --------- Signed-off-by: Luna Liu <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: Yulong Ruan <[email protected]> Co-authored-by: Adam Tackett <[email protected]>
1 parent 73d0065 commit 056bddf

File tree

5 files changed

+258
-10
lines changed

5 files changed

+258
-10
lines changed

changelogs/fragments/10574.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fix:
2+
- Fix field formatter not applied to explore query result ([#10574](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10574))

src/plugins/data/common/data_frames/utils.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
convertResult,
99
DATA_FRAME_TYPES,
1010
formatTimePickerDate,
11+
getFieldType,
1112
IDataFrameErrorResponse,
1213
IDataFrameResponse,
1314
} from '.';
@@ -36,6 +37,52 @@ describe('formatTimePickerDate', () => {
3637
});
3738
});
3839

40+
describe('getFieldType', () => {
41+
it('should return object for struct type', () => {
42+
const field = { type: 'struct' };
43+
const result = getFieldType(field);
44+
expect(result).toBe('object');
45+
});
46+
47+
it('should return date for timestamp type', () => {
48+
const field = { type: 'timestamp' };
49+
const result = getFieldType(field);
50+
expect(result).toBe('date');
51+
});
52+
53+
it('should return date for field name containing date', () => {
54+
const field = { name: 'created_date' };
55+
const result = getFieldType(field);
56+
expect(result).toBe('date');
57+
});
58+
59+
it('should return date for field name containing timestamp', () => {
60+
const field = { name: 'event_timestamp' };
61+
const result = getFieldType(field);
62+
expect(result).toBe('date');
63+
});
64+
65+
it('should return date for field with Date values', () => {
66+
const field = { values: [new Date()] };
67+
const result = getFieldType(field);
68+
expect(result).toBe('date');
69+
});
70+
71+
it('should return date for field with datemath parseable values', () => {
72+
jest.spyOn(datemath, 'isDateTime').mockReturnValue(true);
73+
const field = { values: ['2025-02-13T00:51:50Z'] };
74+
const result = getFieldType(field);
75+
expect(result).toBe('date');
76+
expect(datemath.isDateTime).toHaveBeenCalledWith('2025-02-13T00:51:50Z');
77+
});
78+
79+
it('should return original type if no special conditions match', () => {
80+
const field = { type: 'keyword' };
81+
const result = getFieldType(field);
82+
expect(result).toBe('keyword');
83+
});
84+
});
85+
3986
describe('convertResult', () => {
4087
const mockDateString = '2025-02-13 00:51:50';
4188
const expectedFormattedDate = moment.utc(mockDateString).format('YYYY-MM-DDTHH:mm:ssZ');

src/plugins/data/common/data_frames/utils.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,20 @@ export const convertResult = ({
174174
* @returns field type
175175
*/
176176
export const getFieldType = (field: IFieldType | Partial<IFieldType>): string | undefined => {
177-
const fieldName = field.name?.toLowerCase();
178-
if (fieldName?.includes('date') || fieldName?.includes('timestamp')) {
177+
const rawType = field.type?.toString().toLowerCase();
178+
if (rawType) {
179+
if (rawType === 'struct') return 'object';
180+
if (rawType === 'timestamp') return 'date';
181+
return rawType;
182+
}
183+
184+
const fieldName = field.name?.toLowerCase() ?? '';
185+
if (fieldName.includes('date') || fieldName.includes('timestamp')) {
179186
return 'date';
180187
}
181188
if (field.values?.some((value) => value instanceof Date || datemath.isDateTime(value))) {
182189
return 'date';
183190
}
184-
if (field.type === 'struct') {
185-
return 'object';
186-
}
187191

188192
return field.type;
189193
};

src/plugins/explore/public/application/utils/state_management/actions/query_actions.test.ts

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ jest.mock('moment-timezone', () => {
2525
return moment;
2626
});
2727

28+
jest.mock('./utils', () => ({
29+
buildPPLHistogramQuery: jest.fn((queryString) => queryString),
30+
processRawResultsForHistogram: jest.fn((_queryString, rawResults) => rawResults),
31+
createHistogramConfigWithInterval: jest.fn(() => ({
32+
toDsl: jest.fn().mockReturnValue({}),
33+
aggs: [
34+
{},
35+
{
36+
buckets: {
37+
getInterval: jest.fn(() => ({ interval: '1h', scale: 1 })),
38+
},
39+
},
40+
],
41+
})),
42+
}));
43+
2844
import { configureStore } from '@reduxjs/toolkit';
2945
import {
3046
abortAllActiveQueries,
@@ -208,6 +224,9 @@ describe('Query Actions - Comprehensive Test Suite', () => {
208224
query: {
209225
queryString: {
210226
getQuery: jest.fn().mockReturnValue({ query: '', language: 'PPL' }),
227+
getLanguageService: jest.fn().mockReturnValue({
228+
getLanguage: jest.fn().mockReturnValue({}),
229+
}),
211230
},
212231
filterManager: {
213232
getFilters: jest.fn().mockReturnValue([]),
@@ -640,7 +659,6 @@ describe('Query Actions - Comprehensive Test Suite', () => {
640659
mockBuildPointSeriesData.mockReturnValue([{ x: 1609459200000, y: 5 }] as any);
641660
mockTabifyAggResponse.mockReturnValue({ rows: [], columns: [] } as any);
642661
});
643-
644662
it('should process histogram data when timeFieldName exists', () => {
645663
const rawResults = {
646664
hits: {
@@ -1158,7 +1176,34 @@ describe('Query Actions - Comprehensive Test Suite', () => {
11581176
},
11591177
},
11601178
]),
1179+
aggs: [
1180+
{} as any,
1181+
{
1182+
buckets: {
1183+
getInterval: jest.fn(() => ({ interval: '1h', scale: 1 })),
1184+
},
1185+
} as any,
1186+
],
11611187
} as any);
1188+
mockSearchSource.fetch.mockReset();
1189+
mockSearchSource.fetch.mockResolvedValue({
1190+
hits: {
1191+
hits: [
1192+
{ _id: '1', _source: { field1: 'value1' } },
1193+
{ _id: '2', _source: { field2: 'value2' } },
1194+
],
1195+
total: 2,
1196+
},
1197+
took: 5,
1198+
aggregations: {
1199+
histogram: {
1200+
buckets: [
1201+
{ key: 1609459200000, doc_count: 5 },
1202+
{ key: 1609462800000, doc_count: 3 },
1203+
],
1204+
},
1205+
},
1206+
});
11621207
});
11631208

11641209
it('should execute histogram query with custom interval', async () => {
@@ -1174,6 +1219,92 @@ describe('Query Actions - Comprehensive Test Suite', () => {
11741219

11751220
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
11761221
expect(mockSearchSource.fetch).toHaveBeenCalled();
1222+
expect(mockDispatch).toHaveBeenCalledWith(
1223+
expect.objectContaining({
1224+
type: 'queryEditor/setIndividualQueryStatus',
1225+
payload: expect.objectContaining({
1226+
cacheKey: 'test-cache-key',
1227+
status: expect.objectContaining({
1228+
status: QueryExecutionStatus.READY,
1229+
}),
1230+
}),
1231+
})
1232+
);
1233+
});
1234+
1235+
it('should execute histogram query with formatter in language config', async () => {
1236+
const mockFormatter = jest.fn();
1237+
(mockServices.data.query.queryString.getLanguageService as jest.Mock).mockReturnValue({
1238+
getLanguage: jest.fn().mockReturnValue({
1239+
fields: {
1240+
formatter: mockFormatter,
1241+
},
1242+
}),
1243+
});
1244+
1245+
const params = {
1246+
services: mockServices,
1247+
cacheKey: 'test-cache-key',
1248+
queryString: 'source=logs',
1249+
interval: '5m',
1250+
};
1251+
1252+
const thunk = executeHistogramQuery(params);
1253+
await thunk(mockDispatch, mockGetState, undefined);
1254+
1255+
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
1256+
expect(mockSearchSource.fetch).toHaveBeenCalledWith(
1257+
expect.objectContaining({
1258+
abortSignal: expect.any(Object),
1259+
formatter: mockFormatter,
1260+
})
1261+
);
1262+
expect(mockDispatch).toHaveBeenCalledWith(
1263+
expect.objectContaining({
1264+
type: 'queryEditor/setIndividualQueryStatus',
1265+
payload: expect.objectContaining({
1266+
cacheKey: 'test-cache-key',
1267+
status: expect.objectContaining({
1268+
status: QueryExecutionStatus.READY,
1269+
}),
1270+
}),
1271+
})
1272+
);
1273+
});
1274+
1275+
it('should execute histogram query without formatter in language config', async () => {
1276+
(mockServices.data.query.queryString.getLanguageService as jest.Mock).mockReturnValue({
1277+
getLanguage: jest.fn().mockReturnValue({}),
1278+
});
1279+
1280+
const params = {
1281+
services: mockServices,
1282+
cacheKey: 'test-cache-key',
1283+
queryString: 'source=logs',
1284+
interval: '5m',
1285+
};
1286+
1287+
const thunk = executeHistogramQuery(params);
1288+
await thunk(mockDispatch, mockGetState, undefined);
1289+
1290+
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
1291+
expect(mockSearchSource.fetch).toHaveBeenCalledWith(
1292+
expect.objectContaining({
1293+
abortSignal: expect.any(Object),
1294+
withLongNumeralsSupport: false,
1295+
})
1296+
);
1297+
expect(mockDispatch).toHaveBeenCalledWith(
1298+
expect.objectContaining({
1299+
type: 'queryEditor/setIndividualQueryStatus',
1300+
payload: expect.objectContaining({
1301+
cacheKey: 'test-cache-key',
1302+
status: expect.objectContaining({
1303+
status: QueryExecutionStatus.READY,
1304+
}),
1305+
}),
1306+
})
1307+
);
11771308
});
11781309

11791310
it('should handle missing interval gracefully', async () => {
@@ -1187,6 +1318,18 @@ describe('Query Actions - Comprehensive Test Suite', () => {
11871318
await thunk(mockDispatch, mockGetState, undefined);
11881319

11891320
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
1321+
expect(mockSearchSource.fetch).toHaveBeenCalled();
1322+
expect(mockDispatch).toHaveBeenCalledWith(
1323+
expect.objectContaining({
1324+
type: 'queryEditor/setIndividualQueryStatus',
1325+
payload: expect.objectContaining({
1326+
cacheKey: 'test-cache-key',
1327+
status: expect.objectContaining({
1328+
status: QueryExecutionStatus.READY,
1329+
}),
1330+
}),
1331+
})
1332+
);
11901333
});
11911334

11921335
it('should handle dataView without timeFieldName', async () => {
@@ -1202,16 +1345,32 @@ describe('Query Actions - Comprehensive Test Suite', () => {
12021345

12031346
const thunk = executeHistogramQuery(params);
12041347
await thunk(mockDispatch, mockGetState, undefined);
1205-
1348+
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
12061349
expect(mockSearchSource.fetch).toHaveBeenCalled();
1350+
expect(mockDispatch).toHaveBeenCalledWith(
1351+
expect.objectContaining({
1352+
type: 'queryEditor/setIndividualQueryStatus',
1353+
payload: expect.objectContaining({
1354+
cacheKey: 'test-cache-key',
1355+
status: expect.objectContaining({
1356+
status: QueryExecutionStatus.READY,
1357+
}),
1358+
}),
1359+
})
1360+
);
12071361
});
12081362

12091363
it('should handle search errors gracefully', async () => {
12101364
const error = {
12111365
body: {
12121366
error: 'Search failed',
1213-
message:
1214-
'{"error":{"details":"Query syntax error","reason":"Invalid query","type":"parsing_exception"}}',
1367+
message: JSON.stringify({
1368+
error: {
1369+
details: 'Query syntax error',
1370+
reason: 'Invalid query',
1371+
type: 'parsing_exception',
1372+
},
1373+
}),
12151374
statusCode: 400,
12161375
},
12171376
};
@@ -1235,11 +1394,20 @@ describe('Query Actions - Comprehensive Test Suite', () => {
12351394
// Verify error status is set in Redux
12361395
expect(mockDispatch).toHaveBeenCalledWith(
12371396
expect.objectContaining({
1238-
type: expect.stringContaining('setIndividualQueryStatus'),
1397+
type: 'queryEditor/setIndividualQueryStatus',
12391398
payload: expect.objectContaining({
12401399
cacheKey: 'test-cache-key',
12411400
status: expect.objectContaining({
12421401
status: QueryExecutionStatus.ERROR,
1402+
error: expect.objectContaining({
1403+
error: 'Search failed',
1404+
message: {
1405+
details: 'Query syntax error',
1406+
reason: 'Invalid query',
1407+
type: 'parsing_exception',
1408+
},
1409+
statusCode: 400,
1410+
}),
12431411
}),
12441412
}),
12451413
})
@@ -1384,6 +1552,17 @@ describe('Query Actions - Comprehensive Test Suite', () => {
13841552

13851553
const mockIndexPatterns = dataPublicModule.indexPatterns as any;
13861554
mockIndexPatterns.isDefault.mockReturnValue(true);
1555+
mockSearchSource.fetch.mockReset();
1556+
mockSearchSource.fetch.mockResolvedValue({
1557+
hits: {
1558+
hits: [
1559+
{ _id: '1', _source: { field1: 'value1' } },
1560+
{ _id: '2', _source: { field2: 'value2' } },
1561+
],
1562+
total: 2,
1563+
},
1564+
took: 5,
1565+
});
13871566
});
13881567

13891568
it('should execute tab query successfully', async () => {
@@ -1399,6 +1578,17 @@ describe('Query Actions - Comprehensive Test Suite', () => {
13991578
expect(mockServices.data.dataViews.get).toHaveBeenCalled();
14001579
expect(mockSearchSource.fetch).toHaveBeenCalled();
14011580
expect(setResults).toHaveBeenCalled();
1581+
expect(mockDispatch).toHaveBeenCalledWith(
1582+
expect.objectContaining({
1583+
type: 'queryEditor/setIndividualQueryStatus',
1584+
payload: expect.objectContaining({
1585+
cacheKey: 'test-cache-key',
1586+
status: expect.objectContaining({
1587+
status: QueryExecutionStatus.READY,
1588+
}),
1589+
}),
1590+
})
1591+
);
14021592
});
14031593

14041594
it('should handle missing services gracefully', async () => {

src/plugins/explore/public/application/utils/state_management/actions/query_actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,15 @@ const executeQueryBase = async (
468468
});
469469
}
470470

471+
const languageConfig = services.data.query.queryString
472+
.getLanguageService()
473+
.getLanguage(query.language);
474+
471475
// Execute query
472476
const rawResults = await searchSource.fetch({
473477
abortSignal: abortController.signal,
474478
withLongNumeralsSupport: await services.uiSettings.get('data:withLongNumerals'),
479+
...(languageConfig?.fields?.formatter ? { formatter: languageConfig.fields.formatter } : {}),
475480
});
476481

477482
// Add response stats to inspector

0 commit comments

Comments
 (0)