Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,60 +35,73 @@ const explainLogMessageButtonLabel = i18n.translate(
export function LogAiInsight({ doc }: LogAiInsightProps) {
const apiClient = useApiClient();

const { index, id } = useMemo(() => {
return {
index: doc?.fields.find((field) => field.field === '_index')?.value[0],
id: doc?.fields.find((field) => field.field === '_id')?.value[0],
};
const { index, id, fields } = useMemo(() => {
const idx = doc?.fields.find((field) => field.field === '_index')?.value[0];
const docId = doc?.fields.find((field) => field.field === '_id')?.value[0];
if (typeof idx === 'string' && typeof docId === 'string') {
return { index: idx, id: docId, fields: undefined };
}

const docFields: Record<string, unknown> = {};
for (const entry of doc?.fields ?? []) {
if (entry.value[0] != null) {
docFields[entry.field] = entry.value.length === 1 ? entry.value[0] : entry.value;
}
}
return { index: undefined, id: undefined, fields: docFields };
}, [doc]);

const hasDocIdentity = typeof index === 'string' && typeof id === 'string';
const hasFields = fields !== undefined && Object.keys(fields).length > 0;

const createStream = useCallback(
(signal: AbortSignal) => {
const body = hasDocIdentity ? { index, id } : { fields };

return apiClient.stream('POST /internal/observability_agent_builder/ai_insights/log', {
signal,
params: {
body: {
index: index as string,
id: id as string,
},
},
params: { body },
});
},
[apiClient, index, id]
[apiClient, hasDocIdentity, index, id, fields]
);

if (typeof index !== 'string' || typeof id !== 'string') {
if (!hasDocIdentity && !hasFields) {
return null;
}

const buildAttachments = (summary: string, context: string): AiInsightAttachment[] => [
{
type: 'screen_context',
data: {
app: 'discover',
url: window.location.href,
},
hidden: true,
},
{
type: OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID,
data: {
summary,
context,
attachmentLabel: i18n.translate(
'xpack.observabilityAgentBuilder.logAiInsight.attachmentLabel',
{ defaultMessage: 'Log summary' }
),
const buildAttachments = (summary: string, context: string): AiInsightAttachment[] => {
const attachments: AiInsightAttachment[] = [
{
type: 'screen_context',
data: {
app: 'discover',
url: window.location.href,
},
hidden: true,
},
},
{
type: OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID,
data: {
index,
id,
{
type: OBSERVABILITY_AI_INSIGHT_ATTACHMENT_TYPE_ID,
data: {
summary,
context,
attachmentLabel: i18n.translate(
'xpack.observabilityAgentBuilder.logAiInsight.attachmentLabel',
{ defaultMessage: 'Log summary' }
),
},
},
},
];
];

if (hasDocIdentity) {
attachments.push({
type: OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID,
data: { index, id },
});
}
Comment on lines +96 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when hasDocIdentity is false and hasFields is true, should we add a attachment:

{
  type: OBSERVABILITY_LOG_ATTACHMENT_TYPE_ID,
  data: { fields },
}

Copy link
Copy Markdown
Contributor Author

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?


return attachments;
};

return (
<>
Expand Down
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
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we filter out null on the client side?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

) as LogDocument;
}

const context = await fetchLogContext({
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading