-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[Obs AI] Supports for Logs AI Insight in ES|QL mode in Discover #258595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
20b5401
3a4440f
fb20ab7
a1fbb30
a481512
391e8e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| /* | ||
| * 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 { EMPTY } from 'rxjs'; | ||
| import type { Logger } from '@kbn/core/server'; | ||
| import { httpServerMock } from '@kbn/core/server/mocks'; | ||
| import { getLogAiInsights, type GetLogAiInsightsParams } from './get_log_ai_insights'; | ||
|
|
||
| jest.mock('./get_log_document_by_id', () => ({ | ||
| getLogDocumentById: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('../../tools/get_traces/handler', () => ({ | ||
| getToolHandler: jest.fn().mockResolvedValue({ traces: [] }), | ||
| })); | ||
|
|
||
| jest.mock('../../utils/warning_and_above_log_filter', () => ({ | ||
| isWarningOrAbove: jest.fn().mockReturnValue(false), | ||
| })); | ||
|
|
||
| jest.mock('../../agent/register_observability_agent', () => ({ | ||
| getEntityLinkingInstructions: jest.fn().mockReturnValue(''), | ||
| })); | ||
|
|
||
| jest.mock('./types', () => ({ | ||
| createAiInsightResult: jest.fn((context: string, _connector: unknown, events$: unknown) => ({ | ||
| context, | ||
| events$, | ||
| })), | ||
| })); | ||
|
|
||
| const { getLogDocumentById } = jest.requireMock('./get_log_document_by_id'); | ||
| const { getToolHandler: getTraces } = jest.requireMock('../../tools/get_traces/handler'); | ||
|
|
||
| const mockLogger = { debug: jest.fn(), error: jest.fn() } as unknown as Logger; | ||
|
|
||
| function createBaseParams(overrides: Partial<GetLogAiInsightsParams> = {}): GetLogAiInsightsParams { | ||
| return { | ||
| core: { http: { basePath: { get: () => '' } } } as any, | ||
| plugins: {} as any, | ||
| inferenceClient: { chatComplete: jest.fn().mockReturnValue(EMPTY) } as any, | ||
| connectorId: 'test-connector', | ||
| connector: {} as any, | ||
| request: httpServerMock.createKibanaRequest(), | ||
| esClient: { asCurrentUser: {} } as any, | ||
| logger: mockLogger, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe('getLogAiInsights', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| getTraces.mockResolvedValue({ traces: [] }); | ||
| }); | ||
|
|
||
| it('throws when document is not found by id', async () => { | ||
| getLogDocumentById.mockResolvedValue(undefined); | ||
|
|
||
| await expect( | ||
| getLogAiInsights(createBaseParams({ index: 'logs-test', id: 'missing' })) | ||
| ).rejects.toThrow('Log entry not found'); | ||
| }); | ||
|
|
||
| it('uses index/id path and ignores fields when both are provided', async () => { | ||
| const mockDoc = { '@timestamp': '2026-01-01T00:00:00Z', message: 'from ES' }; | ||
| getLogDocumentById.mockResolvedValue(mockDoc); | ||
|
|
||
| const result = await getLogAiInsights( | ||
| createBaseParams({ | ||
| index: 'logs-test', | ||
| id: 'doc-1', | ||
| fields: { '@timestamp': '2026-01-01T00:00:00Z', message: 'from fields' }, | ||
| }) | ||
| ); | ||
|
|
||
| expect(getLogDocumentById).toHaveBeenCalled(); | ||
| expect(result.context).toContain('from ES'); | ||
| expect(result.context).not.toContain('from fields'); | ||
| }); | ||
|
|
||
| it('uses fields directly and does not fetch from ES when index/id are absent', async () => { | ||
| const fields = { | ||
| '@timestamp': '2026-01-01T00:00:00Z', | ||
| message: 'from fields', | ||
| 'service.name': 'test-svc', | ||
| nullField: null, | ||
| }; | ||
|
|
||
| const result = await getLogAiInsights(createBaseParams({ fields })); | ||
|
|
||
| expect(getLogDocumentById).not.toHaveBeenCalled(); | ||
| expect(result.context).toContain('from fields'); | ||
| expect(result.context).toContain('test-svc'); | ||
| expect(result.context).not.toContain('nullField'); | ||
| }); | ||
|
|
||
| it('skips trace fetch when using fields without trace.id or timestamp', async () => { | ||
| const fields = { message: 'no trace info' }; | ||
|
|
||
| await getLogAiInsights(createBaseParams({ fields })); | ||
|
|
||
| expect(getTraces).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,36 +28,47 @@ import { createAiInsightResult, type AiInsightResult } from './types'; | |
| export interface GetLogAiInsightsParams { | ||
| core: ObservabilityAgentBuilderCoreSetup; | ||
| plugins: ObservabilityAgentBuilderPluginSetupDependencies; | ||
| index: string; | ||
| id: string; | ||
| inferenceClient: InferenceClient; | ||
| connectorId: string; | ||
| connector: InferenceConnector; | ||
| request: KibanaRequest; | ||
| esClient: IScopedClusterClient; | ||
| logger: Logger; | ||
| index?: string; | ||
| id?: string; | ||
| fields?: Record<string, unknown>; | ||
| } | ||
|
|
||
| export async function getLogAiInsights({ | ||
| core, | ||
| plugins, | ||
| index, | ||
| id, | ||
| fields, | ||
| esClient, | ||
| inferenceClient, | ||
| connectorId, | ||
| connector, | ||
| request, | ||
| logger, | ||
| }: GetLogAiInsightsParams): Promise<AiInsightResult> { | ||
| const logEntry = await getLogDocumentById({ | ||
| esClient: esClient.asCurrentUser, | ||
| index, | ||
| id, | ||
| }); | ||
| let logEntry: LogDocument; | ||
|
|
||
| if (!logEntry) { | ||
| throw new Error('Log entry not found'); | ||
| if (typeof index === 'string' && typeof id === 'string') { | ||
| const fetchedById = await getLogDocumentById({ | ||
| esClient: esClient.asCurrentUser, | ||
| index, | ||
| id, | ||
| }); | ||
| if (!fetchedById) { | ||
| throw new Error('Log entry not found'); | ||
| } | ||
| logEntry = fetchedById; | ||
| // esql mode, filter out null entries from passed in fields | ||
| } else { | ||
| logEntry = Object.fromEntries( | ||
| Object.entries(fields ?? {}).filter(([, v]) => v != null) | ||
|
Comment on lines
+69
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we filter out null on the client side?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is. Maybe I don't need this server side one, but I put it in both places. https://github.com/elastic/kibana/pull/258595/changes#diff-55daf8fc97ac45e642ebaa371c2f8b29f77836e3aa41a74b681e3ff7eea9e881R47 |
||
| ) as LogDocument; | ||
| } | ||
|
|
||
| const context = await fetchLogContext({ | ||
|
|
@@ -95,55 +106,82 @@ async function fetchLogContext({ | |
| plugins: ObservabilityAgentBuilderPluginSetupDependencies; | ||
| logger: Logger; | ||
| esClient: IScopedClusterClient; | ||
| index: string; | ||
| id: string; | ||
| index?: string; | ||
| id?: string; | ||
| logEntry: LogDocument; | ||
| }): Promise<string> { | ||
| const logTimestamp = logEntry['@timestamp']; | ||
| if (!logTimestamp) { | ||
| return dedent(` | ||
| <LogEntryFields> | ||
| \`\`\`json | ||
| ${JSON.stringify(logEntry, null, 2)} | ||
| \`\`\` | ||
| </LogEntryFields> | ||
| `); | ||
| } | ||
|
|
||
| const logTime = moment(logTimestamp); | ||
| const windowStart = logTime.clone().subtract(60, 'minutes').toISOString(); | ||
| const windowEnd = logTime.clone().add(60, 'minutes').toISOString(); | ||
|
|
||
| let context = dedent(` | ||
| <LogEntryIndex> | ||
| ${index} | ||
| </LogEntryIndex> | ||
| <LogEntryId> | ||
| ${id} | ||
| </LogEntryId> | ||
| let context = ''; | ||
|
|
||
| if (index) { | ||
| context += dedent(` | ||
| <LogEntryIndex> | ||
| ${index} | ||
| </LogEntryIndex> | ||
| `); | ||
| } | ||
| if (id) { | ||
| context += dedent(` | ||
| <LogEntryId> | ||
| ${id} | ||
| </LogEntryId> | ||
| `); | ||
| } | ||
|
|
||
| context += dedent(` | ||
| <LogEntryFields> | ||
| \`\`\`json | ||
| ${JSON.stringify(logEntry, null, 2)} | ||
| \`\`\` | ||
| </LogEntryFields> | ||
| `); | ||
|
|
||
| try { | ||
| const { traces } = await getTraces({ | ||
| core, | ||
| plugins, | ||
| logger, | ||
| esClient, | ||
| index, | ||
| start: windowStart, | ||
| end: windowEnd, | ||
| kqlFilter: `_id: ${id}`, | ||
| maxTraces: 10, | ||
| maxDocsPerTrace: 100, | ||
| }); | ||
| const trace = traces[0]; | ||
| if (trace) { | ||
| context += dedent(` | ||
| <TraceDocuments> | ||
| Time window: ${windowStart} to ${windowEnd} | ||
| \`\`\`json | ||
| ${JSON.stringify(trace, null, 2)} | ||
| \`\`\` | ||
| </TraceDocuments> | ||
| `); | ||
| // in esql (fields) mode, trace.id may be available | ||
| const traceId = logEntry['trace.id'] as string | undefined; | ||
| const traceFilter = id ? `_id: ${id}` : traceId ? `trace.id: ${traceId}` : undefined; | ||
| const traceIndex = index ?? 'traces-*'; | ||
|
|
||
| if (traceFilter) { | ||
| try { | ||
| const { traces } = await getTraces({ | ||
| core, | ||
| plugins, | ||
| logger, | ||
| esClient, | ||
| index: traceIndex, | ||
| start: windowStart, | ||
| end: windowEnd, | ||
| kqlFilter: traceFilter, | ||
| maxTraces: 10, | ||
| maxDocsPerTrace: 100, | ||
| }); | ||
| const trace = traces[0]; | ||
| if (trace) { | ||
| context += dedent(` | ||
| <TraceDocuments> | ||
| Time window: ${windowStart} to ${windowEnd} | ||
| \`\`\`json | ||
| ${JSON.stringify(trace, null, 2)} | ||
| \`\`\` | ||
| </TraceDocuments> | ||
| `); | ||
| } | ||
| } catch (error) { | ||
| logger.debug(`Failed to fetch traces: ${error.message}`); | ||
| } | ||
| } catch (error) { | ||
| logger.debug(`Failed to fetch traces: ${error.message}`); | ||
| } | ||
|
|
||
| return context; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when
hasDocIdentityisfalseandhasFieldsistrue, should we add a attachment:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess I don't see the point since the whole document is already in the conversation. What would it give us?