Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ec654fa
requirements and design documents
paulstn Oct 1, 2025
b40fcd1
updated code structure
paulstn Oct 1, 2025
99cdf80
modify design slightly
paulstn Oct 7, 2025
25020f8
Add Field Stats tab with table, sorting, and expandable rows
paulstn Oct 8, 2025
95a1c06
Implemented field statistics top level row query execution
paulstn Oct 8, 2025
a5522f6
Expandable Row Components
paulstn Oct 8, 2025
a551a1d
Add i18n for existing table components
paulstn Oct 8, 2025
86d5019
Center searching in progress loader
paulstn Oct 8, 2025
1d62f4b
Fetch real field statistics via PPL queries for expandable row details
paulstn Oct 8, 2025
9051652
Small code cleanup and changes
paulstn Oct 10, 2025
188a67e
Changed field details to use a component registration dogmatic approa…
paulstn Oct 13, 2025
a632636
Remove top values columns not returned
paulstn Oct 13, 2025
63a4f3a
use distinct_count_approx to keep everything within calcite to have a…
paulstn Oct 13, 2025
39054f2
Add a real percentage value to top values
paulstn Oct 13, 2025
a07cf79
Changeset file for PR #10723 created/updated
opensearch-changeset-bot[bot] Oct 14, 2025
9d43993
queries test
paulstn Oct 14, 2025
24f379a
field stats stubs
paulstn Oct 14, 2025
2801bca
move files around
paulstn Oct 14, 2025
9118a2f
field stats container jest test
paulstn Oct 14, 2025
0eab2d3
row details jest test
paulstn Oct 14, 2025
11e167d
column jest test
paulstn Oct 14, 2025
18c0561
reduced columns jest test
paulstn Oct 14, 2025
fca53a2
field_stats_table jest test
paulstn Oct 14, 2025
6501041
utils jest test
paulstn Oct 14, 2025
01342c2
details section tests
paulstn Oct 14, 2025
d753c66
massively reduce details test bloat by refactoring mocks and standard…
paulstn Oct 14, 2025
2727e2d
Merge branch 'main' into field-stats
paulstn Oct 14, 2025
9213669
basic error handling
paulstn Oct 15, 2025
85b2368
small refactor and more tests for errors
paulstn Oct 15, 2025
edd2bab
use actual error message in place of placeholder
paulstn Oct 15, 2025
c51d077
use the more descriptive error body message
paulstn Oct 15, 2025
0adf86a
use emdash in top values details section if there is no doc count
paulstn Oct 15, 2025
52a6ef7
cut down on details section explanation
paulstn Oct 15, 2025
4a9da46
Separate total count from top-level field row query, and calculate do…
paulstn Oct 16, 2025
ad0510e
Update field stat querying to be more accurate with dataset, and use …
paulstn Oct 16, 2025
7059665
Do not display count in top values if it is not given by query
paulstn Oct 16, 2025
fe2571c
update field stats tests
paulstn Oct 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10723.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Explore Field Statistics ([#10723](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10723))
1 change: 1 addition & 0 deletions src/plugins/explore/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const EXPLORE_DEFAULT_LANGUAGE = 'PPL';
export const EXPLORE_LOGS_TAB_ID = 'logs';
export const EXPLORE_PATTERNS_TAB_ID = 'explore_patterns_tab';
export const EXPLORE_VISUALIZATION_TAB_ID = 'explore_visualization_tab';
export const EXPLORE_FIELD_STATS_TAB_ID = 'explore_field_stats_tab';

export enum ExploreFlavor {
Logs = 'logs',
Expand Down
12 changes: 12 additions & 0 deletions src/plugins/explore/public/application/register_tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { LogsTab } from '../components/tabs/logs_tab';
import { FieldStatsTab } from '../components/tabs/field_stats_tab';
import { TabDefinition, TabRegistryService } from '../services/tab_registry/tab_registry_service';
import { ExploreServices } from '../types';
import {
Expand All @@ -12,6 +13,7 @@ import {
EXPLORE_LOGS_TAB_ID,
EXPLORE_VISUALIZATION_TAB_ID,
EXPLORE_PATTERNS_TAB_ID,
EXPLORE_FIELD_STATS_TAB_ID,
} from '../../common';
import { VisTab } from '../components/tabs/vis_tab';
import { getQueryWithSource } from './utils/languages';
Expand Down Expand Up @@ -150,6 +152,16 @@ export const registerBuiltInTabs = (

component: VisTab,
});

// Register Field Stats Tab
tabRegistry.registerTab({
id: EXPLORE_FIELD_STATS_TAB_ID,
label: 'Field Stats',
flavor: [ExploreFlavor.Logs, ExploreFlavor.Metrics],
order: 25,
supportedLanguages: [EXPLORE_DEFAULT_LANGUAGE],
component: FieldStatsTab,
});
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { findTestSubject } from 'test_utils/helpers';
import { dateRangeDetailConfig } from './date_range_detail';
import { executeFieldStatsQuery } from '../field_stats_queries';
import {
createMockFieldStatsItem,
createMockDataset,
createMockServices,
mockQueryResult,
mockEmptyQueryResult,
expectValidDetailConfig,
} from '../utils/field_stats.stubs';

jest.mock('../field_stats_queries');

const mockExecuteFieldStatsQuery = executeFieldStatsQuery as jest.MockedFunction<
typeof executeFieldStatsQuery
>;

describe('DateRangeSection', () => {
const DateRangeSection = dateRangeDetailConfig.component;
const mockField = createMockFieldStatsItem({ name: 'timestamp', type: 'date' });

it('renders date range with dates', () => {
const dateRange = {
earliest: '2025-01-01T10:00:00.000Z',
latest: '2025-01-31T23:59:59.999Z',
};
const component = mountWithIntl(<DateRangeSection data={dateRange} field={mockField} />);
const section = findTestSubject(component, 'dateRangeSection');

expect(section.length).toBe(1);
const text = section.text();
expect(text).toContain('Earliest');
expect(text).toContain('Latest');
expect(text).toContain('Jan 1, 2025');
expect(text).toContain('Jan 31, 2025');
component.unmount();
});

it('renders dashes for null dates', () => {
const dateRange = { earliest: null, latest: null };
const component = mountWithIntl(<DateRangeSection data={dateRange} field={mockField} />);
const section = findTestSubject(component, 'dateRangeSection');
const text = section.text();

expect(text).toContain('Earliest');
expect(text).toContain('Latest');
const dashes = text.match(/—/g);
expect(dashes?.length).toBe(2);
component.unmount();
});
});

describe('dateRangeDetailConfig', () => {
const mockDataset = createMockDataset({ id: 'test-index-pattern', title: 'test-index' });
const mockServices = createMockServices();

beforeEach(() => {
jest.clearAllMocks();
});

it('has valid configuration', () => {
expectValidDetailConfig(dateRangeDetailConfig, 'dateRange', 'Date Range', ['date']);
});

it('fetches and parses date range correctly', async () => {
mockExecuteFieldStatsQuery.mockResolvedValue(
mockQueryResult({
earliest: '2025-01-01T10:00:00.000Z',
latest: '2025-01-31T23:59:59.999Z',
})
);

const result = await dateRangeDetailConfig.fetchData('timestamp', mockDataset, mockServices);

expect(mockExecuteFieldStatsQuery).toHaveBeenCalledWith(
mockServices,
expect.stringContaining('source = test-index'),
'test-index-pattern',
'INDEX_PATTERN'
);
const query = mockExecuteFieldStatsQuery.mock.calls[0][1];
expect(query).toContain('min(`timestamp`) as earliest');
expect(query).toContain('max(`timestamp`) as latest');
expect(result).toEqual({
earliest: '2025-01-01T10:00:00.000Z',
latest: '2025-01-31T23:59:59.999Z',
});
});

it('handles empty results', async () => {
mockExecuteFieldStatsQuery.mockResolvedValue(mockEmptyQueryResult());

const result = await dateRangeDetailConfig.fetchData('timestamp', mockDataset, mockServices);

expect(result).toEqual({ earliest: null, latest: null });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { EuiDescriptionList } from '@elastic/eui';
import { i18n } from '@osd/i18n';
import moment from 'moment';
import { FieldStatsItem, DateRange, DetailSectionConfig } from '../utils/field_stats_types';
import { executeFieldStatsQuery } from '../field_stats_queries';
import { DEFAULT_DATE_FORMAT } from '../utils/constants';

/**
* Query function to fetch date range
*/
const getDateRangeQuery = (index: string, fieldName: string): string => {
return `source = ${index}
| stats min(\`${fieldName}\`) as earliest,
max(\`${fieldName}\`) as latest`;
};

/**
* Component to display date range
*/
interface DateRangeSectionProps {
data: DateRange;
field: FieldStatsItem;
}

const DateRangeSection: React.FC<DateRangeSectionProps> = ({ data, field }) => {
return (
<EuiDescriptionList
type="inline"
listItems={[
{
title: i18n.translate('explore.fieldStats.dateRange.earliestLabel', {
defaultMessage: 'Earliest',
}),
description: data.earliest ? moment.utc(data.earliest).format(DEFAULT_DATE_FORMAT) : '—',
},
{
title: i18n.translate('explore.fieldStats.dateRange.latestLabel', {
defaultMessage: 'Latest',
}),
description: data.latest ? moment.utc(data.latest).format(DEFAULT_DATE_FORMAT) : '—',
},
]}
data-test-subj="dateRangeSection"
/>
);
};

/**
* Date Range Detail Section Configuration
* Displays the earliest and latest dates for date fields
*/
export const dateRangeDetailConfig: DetailSectionConfig<DateRange> = {
id: 'dateRange',
title: i18n.translate('explore.fieldStats.dateRange.sectionTitle', {
defaultMessage: 'Date Range',
}),
applicableToTypes: ['date'],
fetchData: async (fieldName, dataset, services) => {
const query = getDateRangeQuery(dataset.title, fieldName);
const result = await executeFieldStatsQuery(services, query, dataset.id || '', dataset.type);

const hits = result?.hits?.hits || [];
const range = hits[0]?._source || {};
return {
earliest: range.earliest || null,
latest: range.latest || null,
};
},
component: DateRangeSection,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { findTestSubject } from 'test_utils/helpers';
import { examplesDetailConfig } from './examples_detail';
import * as fieldStatsQueries from '../field_stats_queries';
import {
createMockFieldStatsItem,
createMockDataset,
createMockServices,
mockQueryResultWithHits,
mockEmptyQueryResult,
expectValidDetailConfig,
} from '../utils/field_stats.stubs';

jest.mock('../field_stats_queries');

const mockExecuteFieldStatsQuery = fieldStatsQueries.executeFieldStatsQuery as jest.MockedFunction<
typeof fieldStatsQueries.executeFieldStatsQuery
>;

describe('ExamplesSection', () => {
const ExamplesSection = examplesDetailConfig.component;
const mockField = createMockFieldStatsItem({ name: 'testField', type: 'object' });

it('renders examples table with data', () => {
const mockData = [{ value: 'test1' }, { value: 'test2' }, { value: { nested: 'object' } }];
const component = mountWithIntl(<ExamplesSection data={mockData} field={mockField} />);

expect(findTestSubject(component, 'examplesSection').length).toBe(1);
expect(component.text()).toContain('test1');
expect(component.text()).toContain('test2');
component.unmount();
});
});

describe('examplesDetailConfig', () => {
const mockDataset = createMockDataset({ id: 'test-dataset-id', title: 'test-index' });
const mockServices = createMockServices();

beforeEach(() => {
jest.clearAllMocks();
});

it('has valid configuration', () => {
expectValidDetailConfig(examplesDetailConfig, 'examples', 'Examples', [
'geo_point',
'geo_shape',
'binary',
'object',
]);
});

it('fetches and parses example values correctly', async () => {
mockExecuteFieldStatsQuery.mockResolvedValue(
mockQueryResultWithHits([
{ testField: 'value1' },
{ testField: 'value2' },
{ testField: { nested: 'value' } },
])
);

const result = await examplesDetailConfig.fetchData('testField', mockDataset, mockServices);

expect(mockExecuteFieldStatsQuery).toHaveBeenCalledWith(
mockServices,
expect.stringContaining('source = test-index'),
'test-dataset-id',
'INDEX_PATTERN'
);
expect(result).toEqual([
{ value: 'value1' },
{ value: 'value2' },
{ value: { nested: 'value' } },
]);
});

it('handles empty and null values', async () => {
mockExecuteFieldStatsQuery.mockResolvedValue(
mockQueryResultWithHits([
{ testField: 'value1' },
{ testField: null },
{ testField: 'value2' },
{},
])
);

const result = await examplesDetailConfig.fetchData('testField', mockDataset, mockServices);

expect(result).toEqual([{ value: 'value1' }, { value: 'value2' }]);
});

it('handles empty query results', async () => {
mockExecuteFieldStatsQuery.mockResolvedValue(mockEmptyQueryResult());

const result = await examplesDetailConfig.fetchData('testField', mockDataset, mockServices);

expect(result).toEqual([]);
});
});
Loading
Loading