diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.test.ts new file mode 100644 index 0000000000000..588ce36a87223 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getFieldCategory, + resolveInputToConcreteFields, + type ResolvedValidField, +} from './get_field_values_handler'; + +describe('getFieldCategory', () => { + it.each([ + ['keyword', 'keyword'], + ['constant_keyword', 'keyword'], + ['ip', 'keyword'], + ['long', 'numeric'], + ['integer', 'numeric'], + ['short', 'numeric'], + ['byte', 'numeric'], + ['double', 'numeric'], + ['float', 'numeric'], + ['half_float', 'numeric'], + ['scaled_float', 'numeric'], + ['unsigned_long', 'numeric'], + ['date', 'date'], + ['date_nanos', 'date'], + ['boolean', 'boolean'], + ['text', 'text'], + ['match_only_text', 'text'], + ] as const)('maps "%s" to "%s"', (fieldType, expectedCategory) => { + expect(getFieldCategory(fieldType)).toBe(expectedCategory); + }); + + it.each(['geo_point', 'flattened', 'object', 'nested', 'unknown_type'])( + 'returns "unsupported" for "%s"', + (fieldType) => { + expect(getFieldCategory(fieldType)).toBe('unsupported'); + } + ); +}); + +describe('resolveInputToConcreteFields', () => { + const allFieldNames = [ + 'service.name', + 'service.environment', + 'host.name', + 'host.ip', + 'host.os', + '@timestamp', + ]; + + const fieldNameToTypeMap: Record = { + 'service.name': 'keyword', + 'service.environment': 'keyword', + 'host.name': 'keyword', + 'host.ip': 'ip', + 'host.os': undefined, + '@timestamp': 'date', + }; + + describe('literal field name', () => { + it('returns a resolved field when the field exists', () => { + const result = resolveInputToConcreteFields( + 'service.name', + allFieldNames, + fieldNameToTypeMap + ); + + expect(result).toEqual([ + { field: 'service.name', fieldType: 'keyword', category: 'keyword' }, + ]); + }); + + it('returns an error when the field does not exist', () => { + const result = resolveInputToConcreteFields( + 'missing.field', + allFieldNames, + fieldNameToTypeMap + ); + + expect(result).toEqual([ + { input: 'missing.field', error: 'Field "missing.field" not found' }, + ]); + }); + }); + + describe('wildcard pattern', () => { + it('matches multiple fields and returns their categories', () => { + const result = resolveInputToConcreteFields( + 'host.*', + allFieldNames, + fieldNameToTypeMap + ) as ResolvedValidField[]; + + expect(result.map((r) => r.field)).toEqual(['host.name', 'host.ip']); + }); + + it('excludes fields with undefined type (object/nested)', () => { + const result = resolveInputToConcreteFields('host.*', allFieldNames, fieldNameToTypeMap); + + const fieldNames = result + .filter((r): r is ResolvedValidField => 'field' in r) + .map((r) => r.field); + + expect(fieldNames).not.toContain('host.os'); + }); + + it('returns an error when no fields match', () => { + const result = resolveInputToConcreteFields( + 'nonexistent.*', + allFieldNames, + fieldNameToTypeMap + ); + + expect(result).toEqual([ + { input: 'nonexistent.*', error: 'No fields match pattern "nonexistent.*"' }, + ]); + }); + + it('matches across different field categories', () => { + const result = resolveInputToConcreteFields('*', allFieldNames, fieldNameToTypeMap); + + const categories = result + .filter((r): r is ResolvedValidField => 'field' in r) + .map((r) => r.category); + + expect(categories).toContain('keyword'); + expect(categories).toContain('date'); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.ts index b640acf5a916d..2ef945324ce2f 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_field_values_handler.ts @@ -8,6 +8,7 @@ import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient } from '@kbn/core/server'; import { groupBy, keyBy, mapValues } from 'lodash'; +import { minimatch } from 'minimatch'; import { getTypedSearch } from '../../utils/get_typed_search'; import { timeRangeFilter, kqlFilter as toKqlFilter } from '../../utils/dsl_filters'; import { parseDatemath } from '../../utils/time'; @@ -212,13 +213,8 @@ async function getTextFieldSampleValues( ); } -/** Converts a wildcard pattern to a regex */ -function wildcardToRegex(pattern: string): RegExp { - return new RegExp(`^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`); -} - /** Determines the category for a field type */ -function getFieldCategory( +export function getFieldCategory( fieldType: string ): 'keyword' | 'numeric' | 'date' | 'boolean' | 'text' | 'unsupported' { if (KEYWORD_TYPES.includes(fieldType)) return 'keyword'; @@ -229,7 +225,7 @@ function getFieldCategory( return 'unsupported'; } -interface ResolvedValidField { +export interface ResolvedValidField { field: string; fieldType: string; category: ReturnType; @@ -238,28 +234,29 @@ interface ResolvedErrorField { input: string; error: string; } -type ResolvedField = ResolvedValidField | ResolvedErrorField; +export type ResolvedField = ResolvedValidField | ResolvedErrorField; /** Resolves an input (field name or wildcard) to concrete fields or an error */ -function resolveInputToConcreteFields( +export function resolveInputToConcreteFields( input: string, allFieldNames: string[], fieldNameToTypeMap: Record ): ResolvedField[] { const isWildcard = input.includes('*'); - const matchingFields = isWildcard - ? allFieldNames.filter((f) => wildcardToRegex(input).test(f) && fieldNameToTypeMap[f]) - : fieldNameToTypeMap[input] - ? [input] - : []; + if (!isWildcard) { + const fieldType = fieldNameToTypeMap[input]; + if (fieldType) { + return [{ field: input, fieldType, category: getFieldCategory(fieldType) }]; + } + return [{ input, error: `Field "${input}" not found` }]; + } + + const matchingFields = allFieldNames.filter( + (f) => minimatch(f, input, { dot: true }) && fieldNameToTypeMap[f] + ); if (matchingFields.length === 0) { - return [ - { - input, - error: isWildcard ? `No fields match pattern "${input}"` : `Field "${input}" not found`, - }, - ]; + return [{ input, error: `No fields match pattern "${input}"` }]; } return matchingFields.map((field) => ({ diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.test.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.test.ts new file mode 100644 index 0000000000000..ac4ff5f30c647 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { extractFieldPaths } from './get_index_fields_handler'; + +describe('extractFieldPaths', () => { + it('extracts top-level keys from a flat object', () => { + expect(extractFieldPaths({ a: 1, b: 'hello' })).toEqual(['a', 'b']); + }); + + it('extracts dot-notation paths from nested objects', () => { + expect(extractFieldPaths({ a: { b: 1, c: 2 } })).toEqual(['a.b', 'a.c']); + }); + + it('handles deeply nested objects', () => { + expect(extractFieldPaths({ a: { b: { c: { d: 1 } } } })).toEqual(['a.b.c.d']); + }); + + it('treats arrays as leaf values', () => { + expect(extractFieldPaths({ tags: ['foo', 'bar'] })).toEqual(['tags']); + }); + + it('treats null as a leaf value', () => { + expect(extractFieldPaths({ a: null })).toEqual(['a']); + }); + + it('returns an empty array for an empty object', () => { + expect(extractFieldPaths({})).toEqual([]); + }); + + it('handles a mix of nested and leaf values', () => { + const result = extractFieldPaths({ + service: { name: 'web', environment: 'prod' }, + '@timestamp': '2024-01-01', + tags: ['a'], + host: { os: { platform: 'linux' } }, + }); + + expect(result).toEqual([ + 'service.name', + 'service.environment', + '@timestamp', + 'tags', + 'host.os.platform', + ]); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.ts b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.ts index b066830ac4cf6..3c2f7118aacc2 100644 --- a/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.ts +++ b/x-pack/solutions/observability/plugins/observability_agent_builder/server/tools/get_index_info/get_index_fields_handler.ts @@ -28,7 +28,7 @@ const SAMPLE_SIZE = 1000; * Extracts all field paths from a nested object. * e.g., { a: { b: 1, c: 2 } } -> ['a.b', 'a.c'] */ -function extractFieldPaths(obj: Record, prefix = ''): string[] { +export function extractFieldPaths(obj: Record, prefix = ''): string[] { return Object.entries(obj).flatMap(([key, value]) => { const path = prefix ? `${prefix}.${key}` : key; if (value !== null && typeof value === 'object' && !Array.isArray(value)) {