From 84aac3afb519e8229336eb3d1cc9fbb176911b65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 02:30:31 +0000 Subject: [PATCH 1/6] feat: implement real AI callbacks to summarize log/trace/pattern - Add POST /ai/summarize endpoint that uses the configured LLM to generate concise, actionable summaries for individual events and patterns - Add useAISummarize hook in the frontend to call the new endpoint - Update AISummarizeButton and AISummarizePatternButton to use real AI when aiAssistantEnabled is true, falling back to the Easter egg themes when no AI provider is configured - Update AISummaryPanel to support both real AI and Easter egg display modes (no info popover / dismiss for real AI, no italic theme label) HDX-3992 Co-authored-by: Alex Fedotyev --- packages/api/src/routers/api/ai.ts | 56 ++++++++ .../app/src/components/AISummarizeButton.tsx | 132 +++++++++++++++--- .../components/AISummarizePatternButton.tsx | 130 ++++++++++++----- .../components/aiSummarize/AISummaryPanel.tsx | 97 +++++++------ packages/app/src/hooks/ai.ts | 24 +++- 5 files changed, 337 insertions(+), 102 deletions(-) diff --git a/packages/api/src/routers/api/ai.ts b/packages/api/src/routers/api/ai.ts index b8070c3a03..801f84b47c 100644 --- a/packages/api/src/routers/api/ai.ts +++ b/packages/api/src/routers/api/ai.ts @@ -122,4 +122,60 @@ ${JSON.stringify(allFieldsWithKeys.slice(0, 200).map(f => ({ field: f.key, type: }, ); +// --------------------------------------------------------------------------- +// POST /ai/summarize — generate a natural-language summary of a log, trace, or +// pattern using the configured LLM. +// --------------------------------------------------------------------------- + +const summarizeBodySchema = z.object({ + type: z.enum(['event', 'pattern']), + content: z.string().min(1).max(50000), +}); + +router.post( + '/summarize', + validateRequest({ body: summarizeBodySchema }), + async (req, res, next) => { + try { + const model = getAIModel(); + const { type, content } = req.body; + + const systemPrompt = + type === 'pattern' + ? `You are an expert observability engineer. The user will provide a log/trace pattern (a templatized message with occurrence count and sample events). Write a concise, actionable summary (2-4 sentences) that explains: +1. What the pattern represents (the operation or behaviour). +2. Whether it looks healthy, degraded, or erroneous. +3. A concrete next step the operator could take. + +Be direct and technical. Do not use bullet points. Do not repeat the raw pattern verbatim — paraphrase.` + : `You are an expert observability engineer. The user will provide a single log or trace event (including body, attributes, severity, timing, etc.). Write a concise, actionable summary (2-4 sentences) that explains: +1. What happened in this event. +2. Whether it looks healthy or problematic (and why). +3. A concrete next step the operator could take if there is an issue. + +Be direct and technical. Do not use bullet points. Do not repeat the raw event verbatim — paraphrase.`; + + try { + const result = await generateText({ + model, + system: systemPrompt, + experimental_telemetry: { isEnabled: true }, + prompt: content, + }); + + return res.json({ summary: result.text }); + } catch (err) { + if (err instanceof APICallError) { + throw new Api500Error( + `AI Provider Error. Status: ${err.statusCode}. Message: ${err.message}`, + ); + } + throw err; + } + } catch (e) { + next(e); + } + }, +); + export default router; diff --git a/packages/app/src/components/AISummarizeButton.tsx b/packages/app/src/components/AISummarizeButton.tsx index e77b71cc08..2cbb4f874a 100644 --- a/packages/app/src/components/AISummarizeButton.tsx +++ b/packages/app/src/components/AISummarizeButton.tsx @@ -1,6 +1,8 @@ -// Easter egg: April Fools 2026 — see aiSummarize/ for details. import { useCallback, useEffect, useRef, useState } from 'react'; +import api from '@/api'; +import { useAISummarize } from '@/hooks/ai'; + import AISummaryPanel from './aiSummarize/AISummaryPanel'; import { dismissEasterEgg, @@ -10,6 +12,57 @@ import { Theme, } from './aiSummarize'; +function formatEventContent(rowData: RowData, severityText?: string): string { + const parts: string[] = []; + + if (severityText) parts.push(`Severity: ${severityText}`); + + const body = rowData.__hdx_body; + if (body) + parts.push( + `Body: ${typeof body === 'string' ? body : JSON.stringify(body)}`, + ); + + if (rowData.ServiceName) parts.push(`Service: ${rowData.ServiceName}`); + if (rowData.SpanName) parts.push(`Span: ${rowData.SpanName}`); + if (rowData.StatusCode) parts.push(`Status: ${rowData.StatusCode}`); + if (rowData.Duration) parts.push(`Duration: ${rowData.Duration}ns`); + + const attrs = rowData.__hdx_event_attributes; + if (attrs && typeof attrs === 'object') { + const interesting = Object.entries(attrs) + .filter(([, v]) => v != null && v !== '') + .slice(0, 20); + if (interesting.length > 0) { + parts.push( + `Attributes: ${interesting.map(([k, v]) => `${k}=${v}`).join(', ')}`, + ); + } + } + + const res = rowData.__hdx_resource_attributes; + if (res && typeof res === 'object') { + const interesting = Object.entries(res) + .filter(([, v]) => v != null && v !== '') + .slice(0, 10); + if (interesting.length > 0) { + parts.push( + `Resource: ${interesting.map(([k, v]) => `${k}=${v}`).join(', ')}`, + ); + } + } + + const exc = rowData.__hdx_events_exception_attributes; + if (exc && typeof exc === 'object') { + if (exc['exception.type']) + parts.push(`Exception: ${exc['exception.type']}`); + if (exc['exception.message']) + parts.push(`Exception message: ${exc['exception.message']}`); + } + + return parts.join('\n'); +} + export default function AISummarizeButton({ rowData, severityText, @@ -17,53 +70,92 @@ export default function AISummarizeButton({ rowData?: RowData; severityText?: string; }) { + const { data: me } = api.useMe(); + const aiEnabled = me?.aiAssistantEnabled ?? false; + const showEasterEgg = isEasterEggVisible(); + const [result, setResult] = useState<{ text: string; - theme: Theme; + theme?: Theme; } | null>(null); const [isGenerating, setIsGenerating] = useState(false); const [isOpen, setIsOpen] = useState(false); const [dismissed, setDismissed] = useState(false); + const [error, setError] = useState(null); const timerRef = useRef | null>(null); - // Clean up pending timer on unmount. + const summarize = useAISummarize(); + useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); - const handleClick = useCallback(() => { - if (result) { - setIsOpen(prev => !prev); - return; - } + const handleRealAI = useCallback(() => { setIsGenerating(true); setIsOpen(true); - timerRef.current = setTimeout(() => { - setResult(generateSummary(rowData ?? {}, severityText)); - setIsGenerating(false); - timerRef.current = null; - }, 1800); - }, [rowData, severityText, result]); + setError(null); + const content = formatEventContent(rowData ?? {}, severityText); + summarize.mutate( + { type: 'event', content }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, + }, + ); + }, [rowData, severityText, summarize]); - const handleRegenerate = useCallback(() => { + const handleFakeAI = useCallback(() => { setIsGenerating(true); + setIsOpen(true); timerRef.current = setTimeout(() => { setResult(generateSummary(rowData ?? {}, severityText)); setIsGenerating(false); timerRef.current = null; - }, 1200); + }, 1800); }, [rowData, severityText]); + const handleClick = useCallback(() => { + if (result) { + setIsOpen(prev => !prev); + return; + } + if (aiEnabled) { + handleRealAI(); + } else { + handleFakeAI(); + } + }, [result, aiEnabled, handleRealAI, handleFakeAI]); + + const handleRegenerate = useCallback(() => { + setResult(null); + setError(null); + if (aiEnabled) { + handleRealAI(); + } else { + setIsGenerating(true); + timerRef.current = setTimeout(() => { + setResult(generateSummary(rowData ?? {}, severityText)); + setIsGenerating(false); + timerRef.current = null; + }, 1200); + } + }, [aiEnabled, handleRealAI, rowData, severityText]); + const handleDismiss = useCallback(() => { dismissEasterEgg(); setIsOpen(false); - // Let Collapse animate closed before unmounting. setTimeout(() => setDismissed(true), 300); }, []); - if (dismissed || !isEasterEggVisible()) return null; + if (!aiEnabled && (dismissed || !showEasterEgg)) return null; return ( ); } diff --git a/packages/app/src/components/AISummarizePatternButton.tsx b/packages/app/src/components/AISummarizePatternButton.tsx index 39ed412cca..65a2d764e4 100644 --- a/packages/app/src/components/AISummarizePatternButton.tsx +++ b/packages/app/src/components/AISummarizePatternButton.tsx @@ -1,6 +1,7 @@ -// Easter egg: April Fools 2026 — see aiSummarize/ for details. import { useCallback, useEffect, useRef, useState } from 'react'; +import api from '@/api'; +import { useAISummarize } from '@/hooks/ai'; import { Pattern, PATTERN_COLUMN_ALIAS, @@ -16,10 +17,6 @@ import { Theme, } from './aiSummarize'; -/** - * Build a synthetic RowData from the first sample event so the summary - * generators can extract OTel facts (service, severity, body, etc.). - */ function buildRowDataFromSample( pattern: Pattern, serviceNameExpression: string, @@ -31,13 +28,35 @@ function buildRowDataFromSample( __hdx_body: sample[PATTERN_COLUMN_ALIAS], ServiceName: sample[serviceNameExpression], __hdx_severity_text: sample[SEVERITY_TEXT_COLUMN_ALIAS], - // Pass through any other fields the sample may have (attributes, etc.) ...sample, }, severityText: sample[SEVERITY_TEXT_COLUMN_ALIAS], }; } +function formatPatternContent( + pattern: Pattern, + serviceNameExpression: string, +): string { + const parts: string[] = []; + + parts.push(`Pattern: ${pattern.pattern}`); + parts.push(`Occurrences: ${pattern.count}`); + + const samplesSlice = pattern.samples.slice(0, 5); + if (samplesSlice.length > 0) { + parts.push('Sample events:'); + for (const sample of samplesSlice) { + const body = sample[PATTERN_COLUMN_ALIAS] ?? ''; + const svc = sample[serviceNameExpression] ?? ''; + const sev = sample[SEVERITY_TEXT_COLUMN_ALIAS] ?? ''; + parts.push(` - [${sev}] ${svc}: ${body}`); + } + } + + return parts.join('\n'); +} + export default function AISummarizePatternButton({ pattern, serviceNameExpression, @@ -45,60 +64,62 @@ export default function AISummarizePatternButton({ pattern: Pattern; serviceNameExpression: string; }) { + const { data: me } = api.useMe(); + const aiEnabled = me?.aiAssistantEnabled ?? false; + const showEasterEgg = isEasterEggVisible(); + const [result, setResult] = useState<{ text: string; - theme: Theme; + theme?: Theme; } | null>(null); const [isGenerating, setIsGenerating] = useState(false); const [isOpen, setIsOpen] = useState(false); const [dismissed, setDismissed] = useState(false); + const [error, setError] = useState(null); const timerRef = useRef | null>(null); - // Clean up pending timer on unmount. + const summarize = useAISummarize(); + useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); - // Reset when pattern changes. useEffect(() => { setResult(null); setIsOpen(false); setIsGenerating(false); + setError(null); if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }, [pattern]); - const handleClick = useCallback(() => { - if (result) { - setIsOpen(prev => !prev); - return; - } + const handleRealAI = useCallback(() => { setIsGenerating(true); setIsOpen(true); - const { rowData, severityText } = buildRowDataFromSample( - pattern, - serviceNameExpression, + setError(null); + const content = formatPatternContent(pattern, serviceNameExpression); + summarize.mutate( + { type: 'pattern', content }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, + }, ); - timerRef.current = setTimeout(() => { - setResult( - generatePatternSummary( - pattern.pattern, - pattern.count, - rowData, - severityText, - ), - ); - setIsGenerating(false); - timerRef.current = null; - }, 1800); - }, [pattern, serviceNameExpression, result]); + }, [pattern, serviceNameExpression, summarize]); - const handleRegenerate = useCallback(() => { + const handleFakeAI = useCallback(() => { setIsGenerating(true); + setIsOpen(true); const { rowData, severityText } = buildRowDataFromSample( pattern, serviceNameExpression, @@ -114,17 +135,54 @@ export default function AISummarizePatternButton({ ); setIsGenerating(false); timerRef.current = null; - }, 1200); + }, 1800); }, [pattern, serviceNameExpression]); + const handleClick = useCallback(() => { + if (result) { + setIsOpen(prev => !prev); + return; + } + if (aiEnabled) { + handleRealAI(); + } else { + handleFakeAI(); + } + }, [result, aiEnabled, handleRealAI, handleFakeAI]); + + const handleRegenerate = useCallback(() => { + setResult(null); + setError(null); + if (aiEnabled) { + handleRealAI(); + } else { + setIsGenerating(true); + const { rowData, severityText } = buildRowDataFromSample( + pattern, + serviceNameExpression, + ); + timerRef.current = setTimeout(() => { + setResult( + generatePatternSummary( + pattern.pattern, + pattern.count, + rowData, + severityText, + ), + ); + setIsGenerating(false); + timerRef.current = null; + }, 1200); + } + }, [aiEnabled, handleRealAI, pattern, serviceNameExpression]); + const handleDismiss = useCallback(() => { dismissEasterEgg(); setIsOpen(false); - // Let Collapse animate closed before unmounting. setTimeout(() => setDismissed(true), 300); }, []); - if (dismissed || !isEasterEggVisible()) return null; + if (!aiEnabled && (dismissed || !showEasterEgg)) return null; return ( ); } diff --git a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx index 1de7a3e7d9..8a78a34d26 100644 --- a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx +++ b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx @@ -1,4 +1,3 @@ -// Easter egg: April Fools 2026 — shared presentational component for AI Summarize. import { useState } from 'react'; import { Anchor, @@ -21,14 +20,18 @@ export default function AISummaryPanel({ onRegenerate, onDismiss, analyzingLabel = 'Analyzing event data...', + isRealAI = false, + error, }: { isOpen: boolean; isGenerating: boolean; - result: { text: string; theme: Theme } | null; + result: { text: string; theme?: Theme } | null; onToggle: () => void; onRegenerate: () => void; - onDismiss: () => void; + onDismiss?: () => void; analyzingLabel?: string; + isRealAI?: boolean; + error?: string | null; }) { const [infoOpen, setInfoOpen] = useState(false); @@ -61,7 +64,7 @@ export default function AISummaryPanel({ mt={6} radius="sm" style={{ - borderLeft: '3px solid var(--mantine-color-violet-5)', + borderLeft: `3px solid var(--mantine-color-violet-5)`, whiteSpace: 'pre-line', lineHeight: 1.55, }} @@ -70,6 +73,10 @@ export default function AISummaryPanel({ {analyzingLabel} + ) : error ? ( + + {error} + ) : ( <> @@ -83,52 +90,54 @@ export default function AISummaryPanel({ }} /> AI Summary - {result && ( + {!isRealAI && result?.theme && ( {THEME_LABELS[result.theme]} )} - - - setInfoOpen(o => !o)} - style={{ - color: 'var(--mantine-color-dimmed)', - cursor: 'help', - flexShrink: 0, - }} - /> - - - - Happy April Fools! No AI was used. This summary was - generated locally from hand-written phrase templates. Your - data never left the browser. - - { - setInfoOpen(false); - onDismiss(); - }} - style={{ cursor: 'pointer' }} - > - Don't show again - - - + {!isRealAI && onDismiss && ( + + + setInfoOpen(o => !o)} + style={{ + color: 'var(--mantine-color-dimmed)', + cursor: 'help', + flexShrink: 0, + }} + /> + + + + Happy April Fools! No AI was used. This summary was + generated locally from hand-written phrase templates. + Your data never left the browser. + + { + setInfoOpen(false); + onDismiss(); + }} + style={{ cursor: 'pointer' }} + > + Don't show again + + + + )} - + {result?.text} diff --git a/packages/app/src/hooks/ai.ts b/packages/app/src/hooks/ai.ts index 177a1aba3c..2d984e0ed9 100644 --- a/packages/app/src/hooks/ai.ts +++ b/packages/app/src/hooks/ai.ts @@ -1,7 +1,4 @@ -import type { - AILineTableResponse, - SavedChartConfig, -} from '@hyperdx/common-utils/dist/types'; +import type { AILineTableResponse } from '@hyperdx/common-utils/dist/types'; import { useMutation } from '@tanstack/react-query'; import { hdxServer } from '@/api'; @@ -20,3 +17,22 @@ export function useChartAssistant() { }).json(), }); } + +type SummarizeInput = { + type: 'event' | 'pattern'; + content: string; +}; + +type SummarizeResponse = { + summary: string; +}; + +export function useAISummarize() { + return useMutation({ + mutationFn: async ({ type, content }: SummarizeInput) => + hdxServer('ai/summarize', { + method: 'POST', + json: { type, content }, + }).json(), + }); +} From a3161600850c40fb9b833023b9261915a547e30d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 02:49:10 +0000 Subject: [PATCH 2/6] fix: code review fixes + comprehensive tests for AI summarize - Export formatEventContent/formatPatternContent for testability - Always show 'Don't show' link (both real AI and easter egg modes) - Real AI: visible always (not gated by easter egg dates) - Easter egg: still uses dismissEasterEgg() for localStorage persistence - AISummaryPanel: show 'Don't show' link in collapsed state for easy dismissal, remove April Fools popover in real AI mode Tests (42 new): - formatEventContent: 9 tests covering all field extraction paths - formatPatternContent: 3 tests for pattern/samples formatting - AISummarizeButton: 10 tests (real AI, fake AI, dismiss, error, toggle) - AISummarizePatternButton: 5 tests (visibility, AI/fallback, dismiss) - AISummaryPanel: 8 tests (dismiss, error, theme label, real vs fake) - POST /ai/summarize: 7 tests (validation, prompts, error handling) Co-authored-by: Alex Fedotyev --- .../routers/api/__tests__/aiSummarize.test.ts | 179 ++++++ .../app/src/components/AISummarizeButton.tsx | 18 +- .../components/AISummarizePatternButton.tsx | 13 +- .../components/__tests__/AISummarize.test.tsx | 564 ++++++++++++++++++ .../components/aiSummarize/AISummaryPanel.tsx | 36 +- 5 files changed, 788 insertions(+), 22 deletions(-) create mode 100644 packages/api/src/routers/api/__tests__/aiSummarize.test.ts create mode 100644 packages/app/src/components/__tests__/AISummarize.test.tsx diff --git a/packages/api/src/routers/api/__tests__/aiSummarize.test.ts b/packages/api/src/routers/api/__tests__/aiSummarize.test.ts new file mode 100644 index 0000000000..5f5564c18d --- /dev/null +++ b/packages/api/src/routers/api/__tests__/aiSummarize.test.ts @@ -0,0 +1,179 @@ +import type { LanguageModel } from 'ai'; +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; +import request from 'supertest'; + +// --------------------------------------------------------------------------- +// Mock setup — must precede imports that reference mocked modules +// --------------------------------------------------------------------------- + +const mockGenerateText = jest.fn(); + +jest.mock('ai', () => ({ + generateText: (...args: unknown[]) => mockGenerateText(...args), + APICallError: class extends Error { + statusCode: number; + constructor(msg: string, statusCode: number) { + super(msg); + this.name = 'APICallError'; + this.statusCode = statusCode; + } + }, + Output: { object: jest.fn() }, +})); + +const mockModel = { modelId: 'test-model' } as unknown as LanguageModel; + +jest.mock('@/controllers/ai', () => ({ + getAIModel: () => mockModel, + getAIMetadata: jest.fn(), + getChartConfigFromResolvedConfig: jest.fn(), +})); + +jest.mock('@/controllers/sources', () => ({ + getSource: jest.fn(), +})); + +jest.mock('@/middleware/auth', () => ({ + getNonNullUserWithTeam: jest.fn().mockReturnValue({ + teamId: 'team-123', + }), +})); + +jest.mock('@/utils/logger', () => ({ + __esModule: true, + default: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock('@/utils/zod', () => ({ + objectIdSchema: { + _def: { typeName: 'ZodString' }, + parse: (v: string) => v, + }, +})); + +// --------------------------------------------------------------------------- + +import aiRouter from '@/routers/api/ai'; +import { BaseError, StatusCode } from '@/utils/errors'; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/ai', aiRouter); + // Minimal error handler matching the app's pattern + app.use( + (err: BaseError, _req: Request, res: Response, _next: NextFunction) => { + res + .status(err.statusCode ?? StatusCode.INTERNAL_SERVER) + .json({ message: err.name || err.message }); + }, + ); + return app; +} + +describe('POST /ai/summarize', () => { + let app: express.Application; + + beforeAll(() => { + app = buildApp(); + }); + + beforeEach(() => { + mockGenerateText.mockReset(); + }); + + it('rejects missing required fields', async () => { + await request(app).post('/ai/summarize').send({}).expect(400); + }); + + it('rejects invalid type value', async () => { + await request(app) + .post('/ai/summarize') + .send({ type: 'invalid', content: 'hello' }) + .expect(400); + }); + + it('rejects empty content', async () => { + await request(app) + .post('/ai/summarize') + .send({ type: 'event', content: '' }) + .expect(400); + }); + + it('returns summary for event type', async () => { + mockGenerateText.mockResolvedValueOnce({ + text: 'This event represents a healthy HTTP GET request.', + }); + + const res = await request(app) + .post('/ai/summarize') + .send({ type: 'event', content: 'Severity: info\nBody: GET /api/users' }) + .expect(200); + + expect(res.body).toEqual({ + summary: 'This event represents a healthy HTTP GET request.', + }); + + expect(mockGenerateText).toHaveBeenCalledTimes(1); + const call = mockGenerateText.mock.calls[0][0]; + expect(call.model).toBe(mockModel); + expect(call.system).toContain('single log or trace event'); + expect(call.prompt).toContain('GET /api/users'); + }); + + it('returns summary for pattern type', async () => { + mockGenerateText.mockResolvedValueOnce({ + text: 'This pattern shows repeated database queries.', + }); + + const res = await request(app) + .post('/ai/summarize') + .send({ + type: 'pattern', + content: 'Pattern: SELECT * FROM <*>\nOccurrences: 1500', + }) + .expect(200); + + expect(res.body).toEqual({ + summary: 'This pattern shows repeated database queries.', + }); + + const call = mockGenerateText.mock.calls[0][0]; + expect(call.system).toContain('log/trace pattern'); + expect(call.prompt).toContain('SELECT * FROM <*>'); + }); + + it('uses different system prompts for event vs pattern', async () => { + mockGenerateText.mockResolvedValue({ text: 'summary' }); + + await request(app) + .post('/ai/summarize') + .send({ type: 'event', content: 'test event' }); + + await request(app) + .post('/ai/summarize') + .send({ type: 'pattern', content: 'test pattern' }); + + const eventSystem = mockGenerateText.mock.calls[0][0].system; + const patternSystem = mockGenerateText.mock.calls[1][0].system; + + expect(eventSystem).not.toBe(patternSystem); + expect(eventSystem).toContain('What happened in this event'); + expect(patternSystem).toContain('What the pattern represents'); + }); + + it('returns 500 on AI provider error', async () => { + const { APICallError } = jest.requireMock('ai'); + mockGenerateText.mockRejectedValueOnce( + new APICallError('Rate limited', 429), + ); + + const res = await request(app) + .post('/ai/summarize') + .send({ type: 'event', content: 'test' }) + .expect(500); + + expect(res.body.message).toContain('AI Provider Error'); + }); +}); diff --git a/packages/app/src/components/AISummarizeButton.tsx b/packages/app/src/components/AISummarizeButton.tsx index 2cbb4f874a..3af9d79790 100644 --- a/packages/app/src/components/AISummarizeButton.tsx +++ b/packages/app/src/components/AISummarizeButton.tsx @@ -12,7 +12,10 @@ import { Theme, } from './aiSummarize'; -function formatEventContent(rowData: RowData, severityText?: string): string { +export function formatEventContent( + rowData: RowData, + severityText?: string, +): string { const parts: string[] = []; if (severityText) parts.push(`Severity: ${severityText}`); @@ -150,12 +153,17 @@ export default function AISummarizeButton({ }, [aiEnabled, handleRealAI, rowData, severityText]); const handleDismiss = useCallback(() => { - dismissEasterEgg(); + if (!aiEnabled) { + dismissEasterEgg(); + } setIsOpen(false); setTimeout(() => setDismissed(true), 300); - }, []); + }, [aiEnabled]); - if (!aiEnabled && (dismissed || !showEasterEgg)) return null; + // Real AI: always visible unless user dismissed this instance. + // Easter egg: visible only within the time-gated window + not dismissed. + if (dismissed) return null; + if (!aiEnabled && !showEasterEgg) return null; return ( { - dismissEasterEgg(); + if (!aiEnabled) { + dismissEasterEgg(); + } setIsOpen(false); setTimeout(() => setDismissed(true), 300); - }, []); + }, [aiEnabled]); - if (!aiEnabled && (dismissed || !showEasterEgg)) return null; + if (dismissed) return null; + if (!aiEnabled && !showEasterEgg) return null; return ( ({ + useAISummarize: () => ({ + mutate: mockMutate, + isPending: false, + isError: false, + error: null, + }), +})); + +let mockMeData: { aiAssistantEnabled: boolean } | null = null; +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useMe: () => ({ data: mockMeData, isLoading: false }), + }, +})); + +let mockEasterEggVisible = true; +jest.mock('../aiSummarize', () => { + const actual = jest.requireActual('../aiSummarize'); + return { + ...actual, + isEasterEggVisible: () => mockEasterEggVisible, + }; +}); + +// --------------------------------------------------------------------------- +// Pure function tests — formatEventContent +// --------------------------------------------------------------------------- + +describe('formatEventContent', () => { + it('returns empty string for empty rowData', () => { + expect(formatEventContent({})).toBe(''); + }); + + it('includes severity when provided', () => { + const result = formatEventContent({}, 'error'); + expect(result).toBe('Severity: error'); + }); + + it('includes body string', () => { + const result = formatEventContent({ __hdx_body: 'request failed' }); + expect(result).toContain('Body: request failed'); + }); + + it('JSON-stringifies non-string body', () => { + const result = formatEventContent({ __hdx_body: { key: 'val' } }); + expect(result).toContain('Body: {"key":"val"}'); + }); + + it('includes service, span, status, duration', () => { + const result = formatEventContent({ + ServiceName: 'api-svc', + SpanName: 'GET /users', + StatusCode: 'STATUS_CODE_ERROR', + Duration: 5000000, + }); + expect(result).toContain('Service: api-svc'); + expect(result).toContain('Span: GET /users'); + expect(result).toContain('Status: STATUS_CODE_ERROR'); + expect(result).toContain('Duration: 5000000ns'); + }); + + it('includes event attributes (capped at 20)', () => { + const attrs: Record = {}; + for (let i = 0; i < 25; i++) { + attrs[`key${i}`] = `val${i}`; + } + const result = formatEventContent({ + __hdx_event_attributes: attrs, + }); + expect(result).toContain('Attributes:'); + expect(result).toContain('key0=val0'); + // Should be capped at 20 + expect(result).not.toContain('key20=val20'); + }); + + it('includes resource attributes (capped at 10)', () => { + const res: Record = {}; + for (let i = 0; i < 15; i++) { + res[`res${i}`] = `v${i}`; + } + const result = formatEventContent({ + __hdx_resource_attributes: res, + }); + expect(result).toContain('Resource:'); + expect(result).toContain('res0=v0'); + expect(result).not.toContain('res10=v10'); + }); + + it('includes exception info', () => { + const result = formatEventContent({ + __hdx_events_exception_attributes: { + 'exception.type': 'NullPointerException', + 'exception.message': 'obj is null', + }, + }); + expect(result).toContain('Exception: NullPointerException'); + expect(result).toContain('Exception message: obj is null'); + }); + + it('skips empty/null attribute values', () => { + const result = formatEventContent({ + __hdx_event_attributes: { + filled: 'yes', + empty: '', + nul: null, + }, + }); + expect(result).toContain('filled=yes'); + expect(result).not.toContain('empty='); + expect(result).not.toContain('nul='); + }); +}); + +// --------------------------------------------------------------------------- +// Pure function tests — formatPatternContent +// --------------------------------------------------------------------------- + +describe('formatPatternContent', () => { + const makePattern = ( + overrides: Partial<{ + pattern: string; + count: number; + samples: Record[]; + }> = {}, + ) => ({ + pattern: overrides.pattern ?? 'GET /api/<*>', + count: overrides.count ?? 42, + samples: overrides.samples ?? [], + }); + + it('includes pattern name and count', () => { + const result = formatPatternContent(makePattern(), 'ServiceName'); + expect(result).toContain('Pattern: GET /api/<*>'); + expect(result).toContain('Occurrences: 42'); + }); + + it('includes up to 5 samples', () => { + const samples = Array.from({ length: 8 }, (_, i) => ({ + __hdx_pattern_field: `body ${i}`, + ServiceName: `svc-${i}`, + __hdx_severity_text: `info`, + })); + const result = formatPatternContent( + makePattern({ samples }), + 'ServiceName', + ); + expect(result).toContain('Sample events:'); + expect(result).toContain('body 0'); + expect(result).toContain('body 4'); + expect(result).not.toContain('body 5'); + }); + + it('handles empty samples', () => { + const result = formatPatternContent( + makePattern({ samples: [] }), + 'ServiceName', + ); + expect(result).not.toContain('Sample events:'); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests — AISummarizeButton +// --------------------------------------------------------------------------- + +describe('AISummarizeButton', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockMutate.mockReset(); + mockMeData = null; + mockEasterEggVisible = true; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing when AI disabled and easter egg not visible', () => { + mockMeData = { aiAssistantEnabled: false }; + mockEasterEggVisible = false; + renderWithMantine(); + expect(screen.queryByText('Summarize')).not.toBeInTheDocument(); + }); + + it('renders Summarize button when easter egg is visible (no AI)', () => { + mockMeData = { aiAssistantEnabled: false }; + mockEasterEggVisible = true; + renderWithMantine(); + expect(screen.getByText('Summarize')).toBeInTheDocument(); + }); + + it('renders Summarize button when AI is enabled (easter egg off)', () => { + mockMeData = { aiAssistantEnabled: true }; + mockEasterEggVisible = false; + renderWithMantine(); + expect(screen.getByText('Summarize')).toBeInTheDocument(); + }); + + it('shows "Don\'t show" link that dismisses the button', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine(); + const dismissLink = screen.getByText("Don't show"); + expect(dismissLink).toBeInTheDocument(); + + fireEvent.click(dismissLink); + act(() => { + jest.advanceTimersByTime(400); + }); + expect(screen.queryByText('Summarize')).not.toBeInTheDocument(); + }); + + it('uses fake AI (setTimeout) when AI is not enabled', () => { + mockMeData = { aiAssistantEnabled: false }; + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Summarize')); + expect(screen.getByText('Analyzing event data...')).toBeInTheDocument(); + expect(mockMutate).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(screen.getByText('AI Summary')).toBeInTheDocument(); + }); + + it('calls real AI mutate when AI is enabled', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Summarize')); + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith( + { + type: 'event', + content: expect.stringContaining('Severity: error'), + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + + it('displays AI summary after successful mutate', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine(); + + fireEvent.click(screen.getByText('Summarize')); + + const call = mockMutate.mock.calls[0]; + act(() => { + call[1].onSuccess({ summary: 'This event indicates a healthy request.' }); + }); + + expect( + screen.getByText('This event indicates a healthy request.'), + ).toBeInTheDocument(); + }); + + it('displays error message on mutate failure', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine(); + + fireEvent.click(screen.getByText('Summarize')); + + const call = mockMutate.mock.calls[0]; + act(() => { + call[1].onError(new Error('Provider timeout')); + }); + + expect(screen.getByText('Provider timeout')).toBeInTheDocument(); + }); + + it('toggles panel open/closed after result exists', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine(); + + fireEvent.click(screen.getByText('Summarize')); + const call = mockMutate.mock.calls[0]; + act(() => { + call[1].onSuccess({ summary: 'Summary text' }); + }); + + expect(screen.getByText('Summary text')).toBeInTheDocument(); + + // Click to hide + fireEvent.click(screen.getByText('Hide Summary')); + // The collapse will hide content, button label changes back + expect(screen.getByText('Summarize')).toBeInTheDocument(); + }); + + it('shows Regenerate button when result is visible', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine(); + + fireEvent.click(screen.getByText('Summarize')); + const call = mockMutate.mock.calls[0]; + act(() => { + call[1].onSuccess({ summary: 'First summary' }); + }); + + expect(screen.getByText('Regenerate')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Regenerate')); + expect(mockMutate).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests — AISummarizePatternButton +// --------------------------------------------------------------------------- + +describe('AISummarizePatternButton', () => { + const pattern = { + pattern: 'GET /api/<*>', + count: 100, + samples: [ + { + __hdx_pattern_field: 'GET /api/users', + ServiceName: 'web', + __hdx_severity_text: 'info', + }, + ], + }; + + beforeEach(() => { + jest.useFakeTimers(); + mockMutate.mockReset(); + mockMeData = null; + mockEasterEggVisible = true; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing when AI disabled and easter egg not visible', () => { + mockMeData = { aiAssistantEnabled: false }; + mockEasterEggVisible = false; + renderWithMantine( + , + ); + expect(screen.queryByText('Summarize')).not.toBeInTheDocument(); + }); + + it('renders when AI is enabled regardless of easter egg', () => { + mockMeData = { aiAssistantEnabled: true }; + mockEasterEggVisible = false; + renderWithMantine( + , + ); + expect(screen.getByText('Summarize')).toBeInTheDocument(); + }); + + it('calls real AI with pattern type when AI enabled', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Summarize')); + expect(mockMutate).toHaveBeenCalledWith( + { + type: 'pattern', + content: expect.stringContaining('Pattern: GET /api/<*>'), + }, + expect.any(Object), + ); + }); + + it('uses fake AI when AI is not enabled', () => { + mockMeData = { aiAssistantEnabled: false }; + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Summarize')); + expect(mockMutate).not.toHaveBeenCalled(); + expect(screen.getByText('Analyzing pattern data...')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + expect(screen.getByText('AI Summary')).toBeInTheDocument(); + }); + + it('shows "Don\'t show" link and dismisses', () => { + mockMeData = { aiAssistantEnabled: true }; + renderWithMantine( + , + ); + fireEvent.click(screen.getByText("Don't show")); + act(() => { + jest.advanceTimersByTime(400); + }); + expect(screen.queryByText('Summarize')).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests — AISummaryPanel +// --------------------------------------------------------------------------- + +// Direct import of the panel for isolated tests +import AISummaryPanelComponent from '../aiSummarize/AISummaryPanel'; + +describe('AISummaryPanel', () => { + const AISummaryPanel = AISummaryPanelComponent; + + it('shows "Don\'t show" link when onDismiss is provided and panel is collapsed', () => { + renderWithMantine( + , + ); + expect(screen.getByText("Don't show")).toBeInTheDocument(); + }); + + it('hides "Don\'t show" link when panel is open', () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Don't show")).not.toBeInTheDocument(); + }); + + it('does not show "Don\'t show" link when onDismiss is not provided', () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Don't show")).not.toBeInTheDocument(); + }); + + it('shows error text when error prop is set', () => { + renderWithMantine( + , + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('shows theme label for Easter egg mode', () => { + renderWithMantine( + , + ); + expect(screen.getByText('Detective Noir')).toBeInTheDocument(); + }); + + it('hides theme label for real AI mode', () => { + renderWithMantine( + , + ); + expect(screen.queryByText('Detective Noir')).not.toBeInTheDocument(); + }); + + it('does not render info popover in real AI mode', () => { + renderWithMantine( + , + ); + expect(screen.queryByText('Happy April Fools!')).not.toBeInTheDocument(); + }); + + it('uses non-italic text for real AI summaries', () => { + renderWithMantine( + , + ); + const el = screen.getByText('Real AI result'); + expect(el).not.toHaveStyle({ fontStyle: 'italic' }); + }); +}); diff --git a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx index 8a78a34d26..24b5ead29a 100644 --- a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx +++ b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx @@ -57,6 +57,16 @@ export default function AISummaryPanel({ Regenerate )} + {onDismiss && !isOpen && ( + + Don't show + + )} )} - {!isRealAI && onDismiss && ( + {!isRealAI && ( - { - setInfoOpen(false); - onDismiss(); - }} - style={{ cursor: 'pointer' }} - > - Don't show again - + {onDismiss && ( + { + setInfoOpen(false); + onDismiss(); + }} + style={{ cursor: 'pointer' }} + > + Don't show again + + )} )} From 8200995558c66b6422a5dd045c36421075b448c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 02:54:11 +0000 Subject: [PATCH 3/6] fix: add required 'id' field to Pattern test fixtures The Pattern type requires an 'id' field. Added it to all test fixtures to fix the CI TypeScript check. Co-authored-by: Alex Fedotyev --- packages/app/src/components/__tests__/AISummarize.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app/src/components/__tests__/AISummarize.test.tsx b/packages/app/src/components/__tests__/AISummarize.test.tsx index 63f59c3fe4..9a5150e963 100644 --- a/packages/app/src/components/__tests__/AISummarize.test.tsx +++ b/packages/app/src/components/__tests__/AISummarize.test.tsx @@ -142,6 +142,7 @@ describe('formatPatternContent', () => { samples: Record[]; }> = {}, ) => ({ + id: 'pat-1', pattern: overrides.pattern ?? 'GET /api/<*>', count: overrides.count ?? 42, samples: overrides.samples ?? [], @@ -340,6 +341,7 @@ describe('AISummarizeButton', () => { describe('AISummarizePatternButton', () => { const pattern = { + id: 'pat-test', pattern: 'GET /api/<*>', count: 100, samples: [ From 087267c3f425d1ce3b5c134035acf3dc17370fe7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 03:00:40 +0000 Subject: [PATCH 4/6] fix: use correct Pattern/SampleLog types in test fixtures SampleLog requires __hdx_timestamp alongside __hdx_pattern_field. Added the missing field and used proper Pattern typing throughout. Co-authored-by: Alex Fedotyev --- .../src/components/__tests__/AISummarize.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/__tests__/AISummarize.test.tsx b/packages/app/src/components/__tests__/AISummarize.test.tsx index 9a5150e963..dbfb7183fc 100644 --- a/packages/app/src/components/__tests__/AISummarize.test.tsx +++ b/packages/app/src/components/__tests__/AISummarize.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { act, fireEvent, screen } from '@testing-library/react'; +import type { Pattern } from '@/hooks/usePatterns'; + import { default as AISummarizeButton, formatEventContent, @@ -136,12 +138,8 @@ describe('formatEventContent', () => { describe('formatPatternContent', () => { const makePattern = ( - overrides: Partial<{ - pattern: string; - count: number; - samples: Record[]; - }> = {}, - ) => ({ + overrides: Partial> = {}, + ): Pattern => ({ id: 'pat-1', pattern: overrides.pattern ?? 'GET /api/<*>', count: overrides.count ?? 42, @@ -157,6 +155,7 @@ describe('formatPatternContent', () => { it('includes up to 5 samples', () => { const samples = Array.from({ length: 8 }, (_, i) => ({ __hdx_pattern_field: `body ${i}`, + __hdx_timestamp: `2026-01-01T00:00:0${i}`, ServiceName: `svc-${i}`, __hdx_severity_text: `info`, })); @@ -340,13 +339,14 @@ describe('AISummarizeButton', () => { // --------------------------------------------------------------------------- describe('AISummarizePatternButton', () => { - const pattern = { + const pattern: Pattern = { id: 'pat-test', pattern: 'GET /api/<*>', count: 100, samples: [ { __hdx_pattern_field: 'GET /api/users', + __hdx_timestamp: '2026-01-01T00:00:00', ServiceName: 'web', __hdx_severity_text: 'info', }, From a8a39856c4da13e7b8a6803aa1efca98d1278720 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 14 Apr 2026 20:04:12 -0700 Subject: [PATCH 5/6] feat: improve AI summarize with trace context, tone picker, and markdown output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enrich trace summaries with full trace context (span groups with count/sum/p50 durations, error spans with details) - Add tone/style picker (noir, attenborough, shakespeare) gated behind ?smart=true — persisted in localStorage, auto-regenerates on change - Render AI output as markdown with highlighted key details - Improve prompts: terse for healthy events, focused for errors - Enrich pattern summaries with sample attributes - Add env-local-preload.js so .env.development.local overrides work - Fix react-markdown ESM mock for Jest --- packages/api/eslint.config.mjs | 1 + packages/api/package.json | 4 +- packages/api/scripts/env-local-preload.js | 16 +++ packages/api/src/routers/api/ai.ts | 51 ++++++-- packages/app/jest.config.js | 1 + packages/app/src/__mocks__/react-markdown.tsx | 10 ++ .../app/src/components/AISummarizeButton.tsx | 68 +++++++--- .../components/AISummarizePatternButton.tsx | 83 +++++++++--- .../app/src/components/DBRowSidePanel.tsx | 22 +++- .../src/components/DBRowSidePanelHeader.tsx | 9 +- .../components/__tests__/AISummarize.test.tsx | 8 +- .../components/aiSummarize/AISummaryPanel.tsx | 86 ++++++++++++- .../app/src/components/aiSummarize/helpers.ts | 37 ++++++ .../app/src/components/aiSummarize/index.ts | 13 +- .../components/aiSummarize/traceContext.ts | 118 ++++++++++++++++++ packages/app/src/hooks/ai.ts | 5 +- 16 files changed, 467 insertions(+), 65 deletions(-) create mode 100644 packages/api/scripts/env-local-preload.js create mode 100644 packages/app/src/__mocks__/react-markdown.tsx create mode 100644 packages/app/src/components/aiSummarize/traceContext.ts diff --git a/packages/api/eslint.config.mjs b/packages/api/eslint.config.mjs index 5c926d4224..1d51b76b53 100644 --- a/packages/api/eslint.config.mjs +++ b/packages/api/eslint.config.mjs @@ -19,6 +19,7 @@ export default [ 'src/coverage/**', 'migrations/**', 'migrate-mongo-config.ts', + 'scripts/**', '**/*.config.js', '**/*.config.mjs', 'jest.config.js', diff --git a/packages/api/package.json b/packages/api/package.json index 60a1da37bc..a26980022d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -81,8 +81,8 @@ }, "scripts": { "start": "node ./build/index.js", - "dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts", - "dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts", + "dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts", + "dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts", "build": "rimraf ./build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/", "lint": "npx eslint --quiet . --ext .ts", "lint:fix": "npx eslint . --ext .ts --fix", diff --git a/packages/api/scripts/env-local-preload.js b/packages/api/scripts/env-local-preload.js new file mode 100644 index 0000000000..c2fec1c1f0 --- /dev/null +++ b/packages/api/scripts/env-local-preload.js @@ -0,0 +1,16 @@ +// Preload .env.development.local (if it exists) before dotenv-expand loads +// .env.development. Because dotenv never overwrites existing vars, values +// from the .local file take precedence — matching the Next.js convention. +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); + +const localPath = path.resolve( + __dirname, + '..', + (process.env.DOTENV_CONFIG_PATH || '.env.development') + '.local', +); + +if (fs.existsSync(localPath)) { + dotenv.config({ path: localPath }); +} diff --git a/packages/api/src/routers/api/ai.ts b/packages/api/src/routers/api/ai.ts index 801f84b47c..c9f8704a07 100644 --- a/packages/api/src/routers/api/ai.ts +++ b/packages/api/src/routers/api/ai.ts @@ -127,33 +127,58 @@ ${JSON.stringify(allFieldsWithKeys.slice(0, 200).map(f => ({ field: f.key, type: // pattern using the configured LLM. // --------------------------------------------------------------------------- +const TONE_VALUES = ['default', 'noir', 'attenborough', 'shakespeare'] as const; +type Tone = (typeof TONE_VALUES)[number]; + const summarizeBodySchema = z.object({ type: z.enum(['event', 'pattern']), content: z.string().min(1).max(50000), + tone: z.enum(TONE_VALUES).optional(), }); +// Hardcoded tone modifiers — never accept freeform style text from the client. +const TONE_SUFFIXES: Record, string> = { + noir: 'Write in the style of a hard-boiled detective noir narrator.', + attenborough: + 'Write in the style of Sir David Attenborough narrating a nature documentary.', + shakespeare: 'Write in the style of a Shakespearean dramatic monologue.', +}; + router.post( '/summarize', validateRequest({ body: summarizeBodySchema }), async (req, res, next) => { try { const model = getAIModel(); - const { type, content } = req.body; + const { type, content, tone } = req.body; + + const toneInstruction = + tone && tone !== 'default' ? `\n\n${TONE_SUFFIXES[tone]}` : ''; + + const formatInstruction = ` + +Format: +- Use **bold** for key details: service names, error types, status codes, durations. +- Use \`code\` for specific values: config keys, connection strings, env vars. +- Separate distinct points with line breaks. +- Keep total length under 4 sentences.`; const systemPrompt = type === 'pattern' - ? `You are an expert observability engineer. The user will provide a log/trace pattern (a templatized message with occurrence count and sample events). Write a concise, actionable summary (2-4 sentences) that explains: -1. What the pattern represents (the operation or behaviour). -2. Whether it looks healthy, degraded, or erroneous. -3. A concrete next step the operator could take. - -Be direct and technical. Do not use bullet points. Do not repeat the raw pattern verbatim — paraphrase.` - : `You are an expert observability engineer. The user will provide a single log or trace event (including body, attributes, severity, timing, etc.). Write a concise, actionable summary (2-4 sentences) that explains: -1. What happened in this event. -2. Whether it looks healthy or problematic (and why). -3. A concrete next step the operator could take if there is an issue. - -Be direct and technical. Do not use bullet points. Do not repeat the raw event verbatim — paraphrase.`; + ? `You are an expert observability engineer. The user will provide a log/trace pattern (a templatized message with occurrence count and sample events). Summarize it for an operator scanning a dashboard. + +Rules: +- Lead with what matters: errors, failures, or elevated latency come first. +- If the pattern is healthy and routine, say so in ONE sentence and stop — do not invent concerns. +- If there is a real problem, explain what is wrong and one concrete next step (2-3 sentences max). +- Be terse and technical. Do not repeat the raw pattern — paraphrase.${formatInstruction}${toneInstruction}` + : `You are an expert observability engineer. The user will provide a single log or trace event (body, attributes, severity, timing, etc.). Summarize it for an operator scanning a dashboard. + +Rules: +- Lead with what matters: errors, failures, or elevated latency come first. +- If the event is healthy and routine, say so in ONE sentence and stop — do not invent concerns. +- If there is a real problem, explain what is wrong and one concrete next step (2-3 sentences max). +- Be terse and technical. Do not repeat the raw event — paraphrase.${formatInstruction}${toneInstruction}`; try { const result = await generateText({ diff --git a/packages/app/jest.config.js b/packages/app/jest.config.js index f45dc96e0d..e406e1e0e0 100644 --- a/packages/app/jest.config.js +++ b/packages/app/jest.config.js @@ -20,6 +20,7 @@ module.exports = { '^@/(.*)$': '/src/$1', '^ky-universal$': '/src/__mocks__/ky-universal.ts', '^ky$': '/src/__mocks__/ky-universal.ts', + '^react-markdown$': '/src/__mocks__/react-markdown.tsx', }, setupFilesAfterEnv: ['/src/setupTests.tsx'], }; diff --git a/packages/app/src/__mocks__/react-markdown.tsx b/packages/app/src/__mocks__/react-markdown.tsx new file mode 100644 index 0000000000..faf4e7cda6 --- /dev/null +++ b/packages/app/src/__mocks__/react-markdown.tsx @@ -0,0 +1,10 @@ +// Jest mock for react-markdown (ESM-only module that Jest can't transform). +// Renders children as plain text — sufficient for unit tests. +export default function Markdown({ + children, +}: { + children?: string; + components?: Record; +}) { + return
{children}
; +} diff --git a/packages/app/src/components/AISummarizeButton.tsx b/packages/app/src/components/AISummarizeButton.tsx index 3af9d79790..09f87f52e8 100644 --- a/packages/app/src/components/AISummarizeButton.tsx +++ b/packages/app/src/components/AISummarizeButton.tsx @@ -5,16 +5,23 @@ import { useAISummarize } from '@/hooks/ai'; import AISummaryPanel from './aiSummarize/AISummaryPanel'; import { + AISummarizeTone, + buildTraceContext, dismissEasterEgg, generateSummary, + getSavedTone, isEasterEggVisible, + isSmartMode, RowData, + saveTone, Theme, + TraceSpan, } from './aiSummarize'; export function formatEventContent( rowData: RowData, severityText?: string, + traceContext?: string, ): string { const parts: string[] = []; @@ -63,19 +70,27 @@ export function formatEventContent( parts.push(`Exception message: ${exc['exception.message']}`); } + if (traceContext) { + parts.push(''); + parts.push(traceContext); + } + return parts.join('\n'); } export default function AISummarizeButton({ rowData, severityText, + traceSpans, }: { rowData?: RowData; severityText?: string; + traceSpans?: TraceSpan[]; }) { const { data: me } = api.useMe(); const aiEnabled = me?.aiAssistantEnabled ?? false; const showEasterEgg = isEasterEggVisible(); + const smartMode = isSmartMode(); const [result, setResult] = useState<{ text: string; @@ -85,6 +100,7 @@ export default function AISummarizeButton({ const [isOpen, setIsOpen] = useState(false); const [dismissed, setDismissed] = useState(false); const [error, setError] = useState(null); + const [tone, setTone] = useState(getSavedTone); const timerRef = useRef | null>(null); const summarize = useAISummarize(); @@ -95,25 +111,29 @@ export default function AISummarizeButton({ }; }, []); - const handleRealAI = useCallback(() => { - setIsGenerating(true); - setIsOpen(true); - setError(null); - const content = formatEventContent(rowData ?? {}, severityText); - summarize.mutate( - { type: 'event', content }, - { - onSuccess: data => { - setResult({ text: data.summary }); - setIsGenerating(false); - }, - onError: err => { - setError(err.message || 'Failed to generate summary'); - setIsGenerating(false); + const handleRealAI = useCallback( + (toneOverride?: AISummarizeTone) => { + setIsGenerating(true); + setIsOpen(true); + setError(null); + const traceCtx = traceSpans ? buildTraceContext(traceSpans) : undefined; + const content = formatEventContent(rowData ?? {}, severityText, traceCtx); + summarize.mutate( + { type: 'event', content, tone: toneOverride ?? tone }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, }, - }, - ); - }, [rowData, severityText, summarize]); + ); + }, + [rowData, severityText, summarize, tone, traceSpans], + ); const handleFakeAI = useCallback(() => { setIsGenerating(true); @@ -160,6 +180,16 @@ export default function AISummarizeButton({ setTimeout(() => setDismissed(true), 300); }, [aiEnabled]); + const handleToneChange = useCallback( + (t: AISummarizeTone) => { + setTone(t); + saveTone(t); + setResult(null); + handleRealAI(t); + }, + [handleRealAI], + ); + // Real AI: always visible unless user dismissed this instance. // Easter egg: visible only within the time-gated window + not dismissed. if (dismissed) return null; @@ -176,6 +206,8 @@ export default function AISummarizeButton({ analyzingLabel="Analyzing event data..." isRealAI={aiEnabled} error={error} + tone={tone} + onToneChange={aiEnabled && smartMode ? handleToneChange : undefined} /> ); } diff --git a/packages/app/src/components/AISummarizePatternButton.tsx b/packages/app/src/components/AISummarizePatternButton.tsx index c1167d080a..b9c3918bd8 100644 --- a/packages/app/src/components/AISummarizePatternButton.tsx +++ b/packages/app/src/components/AISummarizePatternButton.tsx @@ -10,10 +10,14 @@ import { import AISummaryPanel from './aiSummarize/AISummaryPanel'; import { + AISummarizeTone, dismissEasterEgg, generatePatternSummary, + getSavedTone, isEasterEggVisible, + isSmartMode, RowData, + saveTone, Theme, } from './aiSummarize'; @@ -34,6 +38,14 @@ function buildRowDataFromSample( }; } +// Keys that are already shown elsewhere or not useful for AI context +const SKIP_KEYS = new Set([ + PATTERN_COLUMN_ALIAS, + SEVERITY_TEXT_COLUMN_ALIAS, + '__hdx_pk', + 'SortKey', +]); + export function formatPatternContent( pattern: Pattern, serviceNameExpression: string, @@ -51,6 +63,24 @@ export function formatPatternContent( const svc = sample[serviceNameExpression] ?? ''; const sev = sample[SEVERITY_TEXT_COLUMN_ALIAS] ?? ''; parts.push(` - [${sev}] ${svc}: ${body}`); + + // Include interesting attributes from the first sample only (to save tokens) + if (sample === samplesSlice[0]) { + const attrs = Object.entries(sample) + .filter( + ([k, v]) => + v != null && + v !== '' && + !SKIP_KEYS.has(k) && + k !== serviceNameExpression, + ) + .slice(0, 15); + if (attrs.length > 0) { + parts.push( + ` Attributes: ${attrs.map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`).join(', ')}`, + ); + } + } } } @@ -67,6 +97,7 @@ export default function AISummarizePatternButton({ const { data: me } = api.useMe(); const aiEnabled = me?.aiAssistantEnabled ?? false; const showEasterEgg = isEasterEggVisible(); + const smartMode = isSmartMode(); const [result, setResult] = useState<{ text: string; @@ -76,6 +107,7 @@ export default function AISummarizePatternButton({ const [isOpen, setIsOpen] = useState(false); const [dismissed, setDismissed] = useState(false); const [error, setError] = useState(null); + const [tone, setTone] = useState(getSavedTone); const timerRef = useRef | null>(null); const summarize = useAISummarize(); @@ -97,25 +129,28 @@ export default function AISummarizePatternButton({ } }, [pattern]); - const handleRealAI = useCallback(() => { - setIsGenerating(true); - setIsOpen(true); - setError(null); - const content = formatPatternContent(pattern, serviceNameExpression); - summarize.mutate( - { type: 'pattern', content }, - { - onSuccess: data => { - setResult({ text: data.summary }); - setIsGenerating(false); - }, - onError: err => { - setError(err.message || 'Failed to generate summary'); - setIsGenerating(false); + const handleRealAI = useCallback( + (toneOverride?: AISummarizeTone) => { + setIsGenerating(true); + setIsOpen(true); + setError(null); + const content = formatPatternContent(pattern, serviceNameExpression); + summarize.mutate( + { type: 'pattern', content, tone: toneOverride ?? tone }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, }, - }, - ); - }, [pattern, serviceNameExpression, summarize]); + ); + }, + [pattern, serviceNameExpression, summarize, tone], + ); const handleFakeAI = useCallback(() => { setIsGenerating(true); @@ -184,6 +219,16 @@ export default function AISummarizePatternButton({ setTimeout(() => setDismissed(true), 300); }, [aiEnabled]); + const handleToneChange = useCallback( + (t: AISummarizeTone) => { + setTone(t); + saveTone(t); + setResult(null); + handleRealAI(t); + }, + [handleRealAI], + ); + if (dismissed) return null; if (!aiEnabled && !showEasterEgg) return null; @@ -198,6 +243,8 @@ export default function AISummarizePatternButton({ analyzingLabel="Analyzing pattern data..." isRealAI={aiEnabled} error={error} + tone={tone} + onToneChange={aiEnabled && smartMode ? handleToneChange : undefined} /> ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 6119936c85..823179bf34 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -32,7 +32,7 @@ import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements'; -import { getEventBody } from '@/source'; +import { getEventBody, useSource } from '@/source'; import TabBar from '@/TabBar'; import { SearchConfig } from '@/types'; import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes'; @@ -45,6 +45,7 @@ import { RowDataPanel, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import { DBSessionPanel, useSessionId } from './DBSessionPanel'; import DBTracePanel from './DBTracePanel'; +import { useEventsAroundFocus } from './DBTraceWaterfallChart'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -275,6 +276,24 @@ const DBRowSidePanel = ({ const enableServiceMap = traceId && traceSourceId; + // Fetch the trace source for AI summarize trace context + const { data: traceSourceData } = useSource({ + id: traceSourceId, + }); + const traceSourceForSpans = + traceSourceData?.kind === SourceKind.Trace ? traceSourceData : null; + + // Fetch all trace spans for AI summarize context. + // Uses TanStack Query caching — if the trace tab already loaded this data, + // this hook returns the cached result without a new ClickHouse query. + const { rows: traceSpans } = useEventsAroundFocus({ + tableSource: traceSourceForSpans!, + focusDate, + dateRange: oneHourRange, + traceId: traceId ?? '', + enabled: !!traceSourceForSpans && !!traceId, + }); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, @@ -329,6 +348,7 @@ const DBRowSidePanel = ({ rowData={normalizedRow} breadcrumbPath={breadcrumbPath} onBreadcrumbClick={handleBreadcrumbClick} + traceSpans={traceSpans} /> {/* ; breadcrumbPath?: BreadcrumbPath; onBreadcrumbClick?: BreadcrumbNavigationCallback; + traceSpans?: TraceSpan[]; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); const { onPropertyAddClick, generateSearchUrl } = @@ -283,7 +286,11 @@ export default function DBRowSidePanelHeader({ )} - + diff --git a/packages/app/src/components/__tests__/AISummarize.test.tsx b/packages/app/src/components/__tests__/AISummarize.test.tsx index dbfb7183fc..5ecfff7c96 100644 --- a/packages/app/src/components/__tests__/AISummarize.test.tsx +++ b/packages/app/src/components/__tests__/AISummarize.test.tsx @@ -259,10 +259,10 @@ describe('AISummarizeButton', () => { fireEvent.click(screen.getByText('Summarize')); expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith( - { + expect.objectContaining({ type: 'event', content: expect.stringContaining('Severity: error'), - }, + }), expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function), @@ -399,10 +399,10 @@ describe('AISummarizePatternButton', () => { fireEvent.click(screen.getByText('Summarize')); expect(mockMutate).toHaveBeenCalledWith( - { + expect.objectContaining({ type: 'pattern', content: expect.stringContaining('Pattern: GET /api/<*>'), - }, + }), expect.any(Object), ); }); diff --git a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx index 24b5ead29a..7d811a8440 100644 --- a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx +++ b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import Markdown from 'react-markdown'; import { Anchor, Button, @@ -10,6 +11,8 @@ import { } from '@mantine/core'; import { IconInfoCircle, IconSparkles } from '@tabler/icons-react'; +import type { AISummarizeTone } from './helpers'; +import { TONE_OPTIONS } from './helpers'; import { Theme, THEME_LABELS } from './logic'; export default function AISummaryPanel({ @@ -22,6 +25,8 @@ export default function AISummaryPanel({ analyzingLabel = 'Analyzing event data...', isRealAI = false, error, + tone, + onToneChange, }: { isOpen: boolean; isGenerating: boolean; @@ -32,6 +37,8 @@ export default function AISummaryPanel({ analyzingLabel?: string; isRealAI?: boolean; error?: string | null; + tone?: AISummarizeTone; + onToneChange?: (tone: AISummarizeTone) => void; }) { const [infoOpen, setInfoOpen] = useState(false); @@ -57,6 +64,37 @@ export default function AISummaryPanel({ Regenerate )} + {onToneChange && isOpen && ( + + )} {onDismiss && !isOpen && ( @@ -149,9 +186,50 @@ export default function AISummaryPanel({ )} - - {result?.text} - + {isRealAI ? ( +
+ ( +

{children}

+ ), + strong: ({ children }) => ( + + {children} + + ), + code: ({ children }) => ( + + {children} + + ), + }} + > + {result?.text ?? ''} +
+
+ ) : ( + + {result?.text} + + )} + {/* tone selector is in the top bar */} )} diff --git a/packages/app/src/components/aiSummarize/helpers.ts b/packages/app/src/components/aiSummarize/helpers.ts index c8f8547c45..9a775f970a 100644 --- a/packages/app/src/components/aiSummarize/helpers.ts +++ b/packages/app/src/components/aiSummarize/helpers.ts @@ -13,6 +13,43 @@ import { renderMs } from '../TimelineChart/utils'; const ALWAYS_ON_END = new Date('2026-04-07T00:00:00').getTime(); const HARD_OFF = new Date('2026-05-01T00:00:00').getTime(); const DISMISS_KEY = 'hdx-ai-summarize-dismissed'; +const TONE_KEY = 'hdx-ai-summarize-tone'; + +export type AISummarizeTone = + | 'default' + | 'noir' + | 'attenborough' + | 'shakespeare'; + +export const TONE_OPTIONS: { value: AISummarizeTone; label: string }[] = [ + { value: 'default', label: 'Default' }, + { value: 'noir', label: 'Detective Noir' }, + { value: 'attenborough', label: 'Nature Documentary' }, + { value: 'shakespeare', label: 'Shakespearean Drama' }, +]; + +export function isSmartMode(): boolean { + if (typeof window === 'undefined') return false; + return new URLSearchParams(window.location.search).get('smart') === 'true'; +} + +export function getSavedTone(): AISummarizeTone { + try { + const v = window.localStorage.getItem(TONE_KEY); + if (v && TONE_OPTIONS.some(o => o.value === v)) return v as AISummarizeTone; + } catch { + // ignore + } + return 'default'; +} + +export function saveTone(tone: AISummarizeTone): void { + try { + window.localStorage.setItem(TONE_KEY, tone); + } catch { + // ignore + } +} // eslint-disable-next-line no-restricted-syntax -- one-time module-level check const NOW_MS = new Date().getTime(); diff --git a/packages/app/src/components/aiSummarize/index.ts b/packages/app/src/components/aiSummarize/index.ts index 4f7551c02e..5a8f4a4578 100644 --- a/packages/app/src/components/aiSummarize/index.ts +++ b/packages/app/src/components/aiSummarize/index.ts @@ -1,5 +1,14 @@ // Easter egg: April Fools 2026 — AI Summarize public API. -export type { RowData } from './helpers'; -export { dismissEasterEgg, isEasterEggVisible } from './helpers'; +export type { AISummarizeTone, RowData } from './helpers'; +export { + dismissEasterEgg, + getSavedTone, + isEasterEggVisible, + isSmartMode, + saveTone, + TONE_OPTIONS, +} from './helpers'; export type { Theme } from './logic'; export { generatePatternSummary, generateSummary } from './logic'; +export type { TraceSpan } from './traceContext'; +export { buildTraceContext } from './traceContext'; diff --git a/packages/app/src/components/aiSummarize/traceContext.ts b/packages/app/src/components/aiSummarize/traceContext.ts new file mode 100644 index 0000000000..a2729fb23f --- /dev/null +++ b/packages/app/src/components/aiSummarize/traceContext.ts @@ -0,0 +1,118 @@ +// Build a compact trace context string for the AI summarize prompt. +// Includes: summary stats, span groups with duration stats, and error spans. + +export interface TraceSpan { + Body?: string; + ServiceName?: string; + Duration?: number; // seconds (f64) + StatusCode?: string; + SeverityText?: string; + SpanId?: string; + ParentSpanId?: string; + SpanAttributes?: Record; +} + +interface SpanGroup { + name: string; + count: number; + errors: number; + durations: number[]; // ms +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; +} + +function fmtMs(ms: number): string { + if (ms < 1) return '<1ms'; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +export function buildTraceContext(spans: TraceSpan[]): string { + if (!spans || spans.length === 0) return ''; + + const totalSpans = spans.length; + const errorSpans = spans.filter( + s => + s.StatusCode === 'Error' || + s.StatusCode === 'STATUS_CODE_ERROR' || + s.SeverityText?.toLowerCase() === 'error', + ); + const errorCount = errorSpans.length; + + // Compute end-to-end duration from span durations + const durationsMs = spans + .map(s => (s.Duration != null ? s.Duration * 1000 : NaN)) + .filter(d => !isNaN(d)); + const maxDuration = durationsMs.length > 0 ? Math.max(...durationsMs) : 0; + + // Group by span name (Body field in trace waterfall) + const groups = new Map(); + for (const span of spans) { + const name = span.Body || '(unknown)'; + let group = groups.get(name); + if (!group) { + group = { name, count: 0, errors: 0, durations: [] }; + groups.set(name, group); + } + group.count++; + if ( + span.StatusCode === 'Error' || + span.StatusCode === 'STATUS_CODE_ERROR' || + span.SeverityText?.toLowerCase() === 'error' + ) { + group.errors++; + } + const dMs = span.Duration != null ? span.Duration * 1000 : NaN; + if (!isNaN(dMs)) group.durations.push(dMs); + } + + // Sort groups by count descending, take top 15 + const sortedGroups = [...groups.values()] + .sort((a, b) => b.count - a.count) + .slice(0, 15); + + const parts: string[] = []; + parts.push( + `Trace Context (${totalSpans} spans, ${errorCount} errors, ${fmtMs(maxDuration)} longest span):`, + ); + parts.push('Span groups:'); + for (const g of sortedGroups) { + const sorted = g.durations.slice().sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + const p50 = percentile(sorted, 50); + const errStr = g.errors > 0 ? `, ${g.errors} errors` : ''; + parts.push( + ` ${g.name}: ${g.count}x${errStr}, sum=${fmtMs(sum)}, p50=${fmtMs(p50)}`, + ); + } + + // Include error spans (capped at 10) with brief details + if (errorSpans.length > 0) { + parts.push('Error spans:'); + for (const span of errorSpans.slice(0, 10)) { + const name = span.Body || '(unknown)'; + const dMs = span.Duration != null ? fmtMs(span.Duration * 1000) : 'n/a'; + const svc = span.ServiceName ? ` (${span.ServiceName})` : ''; + // Include key error attributes if available + const attrs = span.SpanAttributes ?? {}; + const errDetail = + attrs['exception.message'] || + attrs['exception.type'] || + attrs['http.status_code'] || + attrs['db.statement']?.slice(0, 100) || + ''; + parts.push( + ` [ERROR] ${name}${svc} ${dMs}${errDetail ? ' — ' + errDetail : ''}`, + ); + } + if (errorSpans.length > 10) { + parts.push(` ... and ${errorSpans.length - 10} more errors`); + } + } + + return parts.join('\n'); +} diff --git a/packages/app/src/hooks/ai.ts b/packages/app/src/hooks/ai.ts index 2d984e0ed9..70547a1b23 100644 --- a/packages/app/src/hooks/ai.ts +++ b/packages/app/src/hooks/ai.ts @@ -21,6 +21,7 @@ export function useChartAssistant() { type SummarizeInput = { type: 'event' | 'pattern'; content: string; + tone?: 'default' | 'noir' | 'attenborough' | 'shakespeare'; }; type SummarizeResponse = { @@ -29,10 +30,10 @@ type SummarizeResponse = { export function useAISummarize() { return useMutation({ - mutationFn: async ({ type, content }: SummarizeInput) => + mutationFn: async ({ type, content, tone }: SummarizeInput) => hdxServer('ai/summarize', { method: 'POST', - json: { type, content }, + json: { type, content, ...(tone && tone !== 'default' && { tone }) }, }).json(), }); } From d4a48a55f458d9946f5eb957ea742dbd1650d169 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Tue, 14 Apr 2026 20:21:23 -0700 Subject: [PATCH 6/6] fix: address code review feedback for AI summarize - Make trace span fetch lazy (only when user clicks Summarize) - Filter __hdx_ internal keys from pattern sample attributes - Add bounds clamp to percentile calculation - Cap trace context output at 4KB to stay within content limits - Fix stale test assertions for rewritten prompts - Remove stale comment in AISummaryPanel --- .../routers/api/__tests__/aiSummarize.test.ts | 4 +- .../app/src/components/AISummarizeButton.tsx | 100 ++++++++++++++++-- .../components/AISummarizePatternButton.tsx | 1 + .../app/src/components/DBRowSidePanel.tsx | 26 +---- .../src/components/DBRowSidePanelHeader.tsx | 16 ++- .../components/__tests__/AISummarize.test.tsx | 8 ++ .../components/aiSummarize/AISummaryPanel.tsx | 1 - .../app/src/components/aiSummarize/index.ts | 1 - .../components/aiSummarize/traceContext.ts | 11 +- 9 files changed, 130 insertions(+), 38 deletions(-) diff --git a/packages/api/src/routers/api/__tests__/aiSummarize.test.ts b/packages/api/src/routers/api/__tests__/aiSummarize.test.ts index 5f5564c18d..d20f2d6b97 100644 --- a/packages/api/src/routers/api/__tests__/aiSummarize.test.ts +++ b/packages/api/src/routers/api/__tests__/aiSummarize.test.ts @@ -159,8 +159,8 @@ describe('POST /ai/summarize', () => { const patternSystem = mockGenerateText.mock.calls[1][0].system; expect(eventSystem).not.toBe(patternSystem); - expect(eventSystem).toContain('What happened in this event'); - expect(patternSystem).toContain('What the pattern represents'); + expect(eventSystem).toContain('single log or trace event'); + expect(patternSystem).toContain('log/trace pattern'); }); it('returns 500 on AI provider error', async () => { diff --git a/packages/app/src/components/AISummarizeButton.tsx b/packages/app/src/components/AISummarizeButton.tsx index 09f87f52e8..a3061e4895 100644 --- a/packages/app/src/components/AISummarizeButton.tsx +++ b/packages/app/src/components/AISummarizeButton.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import api from '@/api'; import { useAISummarize } from '@/hooks/ai'; +import { useSource } from '@/source'; import AISummaryPanel from './aiSummarize/AISummaryPanel'; import { @@ -15,8 +16,8 @@ import { RowData, saveTone, Theme, - TraceSpan, } from './aiSummarize'; +import { useEventsAroundFocus } from './DBTraceWaterfallChart'; export function formatEventContent( rowData: RowData, @@ -81,11 +82,17 @@ export function formatEventContent( export default function AISummarizeButton({ rowData, severityText, - traceSpans, + traceId, + traceSourceId, + dateRange, + focusDate, }: { rowData?: RowData; severityText?: string; - traceSpans?: TraceSpan[]; + traceId?: string; + traceSourceId?: string | null; + dateRange?: [Date, Date]; + focusDate?: Date; }) { const { data: me } = api.useMe(); const aiEnabled = me?.aiAssistantEnabled ?? false; @@ -101,23 +108,102 @@ export default function AISummarizeButton({ const [dismissed, setDismissed] = useState(false); const [error, setError] = useState(null); const [tone, setTone] = useState(getSavedTone); + // Only fetch trace spans once the user clicks Summarize (lazy loading) + const [traceContextNeeded, setTraceContextNeeded] = useState(false); const timerRef = useRef | null>(null); const summarize = useAISummarize(); + // Lazy trace span fetching — only fires when traceContextNeeded is true + const { data: traceSourceData } = useSource({ + id: traceSourceId ?? undefined, + }); + const traceSource = useMemo( + () => (traceSourceData?.kind === 'trace' ? traceSourceData : undefined), + [traceSourceData], + ); + // Stable fallback date to avoid re-renders from new Date() in render path + const fallbackDate = useMemo(() => new Date(0), []); + const fallbackRange = useMemo( + () => [fallbackDate, fallbackDate] as [Date, Date], + [fallbackDate], + ); + const { rows: traceSpans } = useEventsAroundFocus({ + tableSource: traceSource!, + focusDate: focusDate ?? fallbackDate, + dateRange: dateRange ?? fallbackRange, + traceId: traceId ?? '', + enabled: traceContextNeeded && !!traceSource && !!traceId, + }); + useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); + // When trace spans arrive, fire the AI request + const pendingToneRef = useRef(undefined); + useEffect(() => { + if (traceContextNeeded && traceSpans.length > 0 && isGenerating) { + const traceCtx = buildTraceContext(traceSpans); + const content = formatEventContent(rowData ?? {}, severityText, traceCtx); + summarize.mutate( + { type: 'event', content, tone: pendingToneRef.current ?? tone }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, + }, + ); + setTraceContextNeeded(false); + } + }, [traceContextNeeded, traceSpans]); // eslint-disable-line react-hooks/exhaustive-deps + const handleRealAI = useCallback( (toneOverride?: AISummarizeTone) => { setIsGenerating(true); setIsOpen(true); setError(null); - const traceCtx = traceSpans ? buildTraceContext(traceSpans) : undefined; - const content = formatEventContent(rowData ?? {}, severityText, traceCtx); + pendingToneRef.current = toneOverride; + + // If we have trace context available, use it immediately + if (traceId && traceSource) { + if (traceSpans.length > 0) { + // Spans already cached — fire immediately + const traceCtx = buildTraceContext(traceSpans); + const content = formatEventContent( + rowData ?? {}, + severityText, + traceCtx, + ); + summarize.mutate( + { type: 'event', content, tone: toneOverride ?? tone }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, + }, + ); + } else { + // Trigger lazy fetch — the useEffect above fires the request when data arrives + setTraceContextNeeded(true); + } + return; + } + + // No trace context available — summarize single event + const content = formatEventContent(rowData ?? {}, severityText); summarize.mutate( { type: 'event', content, tone: toneOverride ?? tone }, { @@ -132,7 +218,7 @@ export default function AISummarizeButton({ }, ); }, - [rowData, severityText, summarize, tone, traceSpans], + [rowData, severityText, summarize, tone, traceId, traceSource, traceSpans], ); const handleFakeAI = useCallback(() => { diff --git a/packages/app/src/components/AISummarizePatternButton.tsx b/packages/app/src/components/AISummarizePatternButton.tsx index b9c3918bd8..e4b5372876 100644 --- a/packages/app/src/components/AISummarizePatternButton.tsx +++ b/packages/app/src/components/AISummarizePatternButton.tsx @@ -72,6 +72,7 @@ export function formatPatternContent( v != null && v !== '' && !SKIP_KEYS.has(k) && + !k.startsWith('__hdx_') && k !== serviceNameExpression, ) .slice(0, 15); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 823179bf34..fa25314f5d 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -32,7 +32,7 @@ import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; import { LogSidePanelKbdShortcuts } from '@/LogSidePanelElements'; -import { getEventBody, useSource } from '@/source'; +import { getEventBody } from '@/source'; import TabBar from '@/TabBar'; import { SearchConfig } from '@/types'; import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes'; @@ -45,7 +45,6 @@ import { RowDataPanel, useRowData } from './DBRowDataPanel'; import { RowOverviewPanel } from './DBRowOverviewPanel'; import { DBSessionPanel, useSessionId } from './DBSessionPanel'; import DBTracePanel from './DBTracePanel'; -import { useEventsAroundFocus } from './DBTraceWaterfallChart'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -276,24 +275,6 @@ const DBRowSidePanel = ({ const enableServiceMap = traceId && traceSourceId; - // Fetch the trace source for AI summarize trace context - const { data: traceSourceData } = useSource({ - id: traceSourceId, - }); - const traceSourceForSpans = - traceSourceData?.kind === SourceKind.Trace ? traceSourceData : null; - - // Fetch all trace spans for AI summarize context. - // Uses TanStack Query caching — if the trace tab already loaded this data, - // this hook returns the cached result without a new ClickHouse query. - const { rows: traceSpans } = useEventsAroundFocus({ - tableSource: traceSourceForSpans!, - focusDate, - dateRange: oneHourRange, - traceId: traceId ?? '', - enabled: !!traceSourceForSpans && !!traceId, - }); - const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, @@ -348,7 +329,10 @@ const DBRowSidePanel = ({ rowData={normalizedRow} breadcrumbPath={breadcrumbPath} onBreadcrumbClick={handleBreadcrumbClick} - traceSpans={traceSpans} + traceId={traceId} + traceSourceId={traceSourceId} + dateRange={oneHourRange} + focusDate={focusDate} /> {/* ; breadcrumbPath?: BreadcrumbPath; onBreadcrumbClick?: BreadcrumbNavigationCallback; - traceSpans?: TraceSpan[]; + traceId?: string; + traceSourceId?: string | null; + dateRange?: [Date, Date]; + focusDate?: Date; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); const { onPropertyAddClick, generateSearchUrl } = @@ -289,7 +294,10 @@ export default function DBRowSidePanelHeader({ diff --git a/packages/app/src/components/__tests__/AISummarize.test.tsx b/packages/app/src/components/__tests__/AISummarize.test.tsx index 5ecfff7c96..5a10f1bdb9 100644 --- a/packages/app/src/components/__tests__/AISummarize.test.tsx +++ b/packages/app/src/components/__tests__/AISummarize.test.tsx @@ -34,6 +34,14 @@ jest.mock('@/api', () => ({ }, })); +jest.mock('@/source', () => ({ + useSource: () => ({ data: undefined, isLoading: false }), +})); + +jest.mock('../DBTraceWaterfallChart', () => ({ + useEventsAroundFocus: () => ({ rows: [], meta: [], isFetching: false }), +})); + let mockEasterEggVisible = true; jest.mock('../aiSummarize', () => { const actual = jest.requireActual('../aiSummarize'); diff --git a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx index 7d811a8440..9978edbd6c 100644 --- a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx +++ b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx @@ -229,7 +229,6 @@ export default function AISummaryPanel({ {result?.text} )} - {/* tone selector is in the top bar */} )} diff --git a/packages/app/src/components/aiSummarize/index.ts b/packages/app/src/components/aiSummarize/index.ts index 5a8f4a4578..ef32df6bec 100644 --- a/packages/app/src/components/aiSummarize/index.ts +++ b/packages/app/src/components/aiSummarize/index.ts @@ -10,5 +10,4 @@ export { } from './helpers'; export type { Theme } from './logic'; export { generatePatternSummary, generateSummary } from './logic'; -export type { TraceSpan } from './traceContext'; export { buildTraceContext } from './traceContext'; diff --git a/packages/app/src/components/aiSummarize/traceContext.ts b/packages/app/src/components/aiSummarize/traceContext.ts index a2729fb23f..434109b8a0 100644 --- a/packages/app/src/components/aiSummarize/traceContext.ts +++ b/packages/app/src/components/aiSummarize/traceContext.ts @@ -22,7 +22,7 @@ interface SpanGroup { function percentile(sorted: number[], p: number): number { if (sorted.length === 0) return 0; const idx = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, idx)]; + return sorted[Math.min(Math.max(0, idx), sorted.length - 1)]; } function fmtMs(ms: number): string { @@ -31,6 +31,9 @@ function fmtMs(ms: number): string { return `${(ms / 1000).toFixed(2)}s`; } +// Cap the trace context to ~4KB to stay well within the 50KB content limit +const MAX_TRACE_CONTEXT_CHARS = 4000; + export function buildTraceContext(spans: TraceSpan[]): string { if (!spans || spans.length === 0) return ''; @@ -114,5 +117,9 @@ export function buildTraceContext(spans: TraceSpan[]): string { } } - return parts.join('\n'); + const result = parts.join('\n'); + if (result.length > MAX_TRACE_CONTEXT_CHARS) { + return result.slice(0, MAX_TRACE_CONTEXT_CHARS - 20) + '\n... (truncated)'; + } + return result; }