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/__tests__/aiSummarize.test.ts b/packages/api/src/routers/api/__tests__/aiSummarize.test.ts new file mode 100644 index 0000000000..d20f2d6b97 --- /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('single log or trace event'); + expect(patternSystem).toContain('log/trace pattern'); + }); + + 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/api/src/routers/api/ai.ts b/packages/api/src/routers/api/ai.ts index b8070c3a03..c9f8704a07 100644 --- a/packages/api/src/routers/api/ai.ts +++ b/packages/api/src/routers/api/ai.ts @@ -122,4 +122,85 @@ ${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 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, 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). 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({ + 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/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 e77b71cc08..a3061e4895 100644 --- a/packages/app/src/components/AISummarizeButton.tsx +++ b/packages/app/src/components/AISummarizeButton.tsx @@ -1,43 +1,227 @@ -// Easter egg: April Fools 2026 — see aiSummarize/ for details. -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 { + AISummarizeTone, + buildTraceContext, dismissEasterEgg, generateSummary, + getSavedTone, isEasterEggVisible, + isSmartMode, RowData, + saveTone, Theme, } from './aiSummarize'; +import { useEventsAroundFocus } from './DBTraceWaterfallChart'; + +export function formatEventContent( + rowData: RowData, + severityText?: string, + traceContext?: 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']}`); + } + + if (traceContext) { + parts.push(''); + parts.push(traceContext); + } + + return parts.join('\n'); +} export default function AISummarizeButton({ rowData, severityText, + traceId, + traceSourceId, + dateRange, + focusDate, }: { rowData?: RowData; severityText?: string; + traceId?: string; + traceSourceId?: string | null; + dateRange?: [Date, Date]; + focusDate?: Date; }) { + const { data: me } = api.useMe(); + const aiEnabled = me?.aiAssistantEnabled ?? false; + const showEasterEgg = isEasterEggVisible(); + const smartMode = isSmartMode(); + 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 [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); - // Clean up pending timer on unmount. + 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); }; }, []); - const handleClick = useCallback(() => { - if (result) { - setIsOpen(prev => !prev); - return; + // 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); + 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 }, + { + onSuccess: data => { + setResult({ text: data.summary }); + setIsGenerating(false); + }, + onError: err => { + setError(err.message || 'Failed to generate summary'); + setIsGenerating(false); + }, + }, + ); + }, + [rowData, severityText, summarize, tone, traceId, traceSource, traceSpans], + ); + + const handleFakeAI = useCallback(() => { setIsGenerating(true); setIsOpen(true); timerRef.current = setTimeout(() => { @@ -45,25 +229,57 @@ export default function AISummarizeButton({ setIsGenerating(false); timerRef.current = null; }, 1800); - }, [rowData, severityText, result]); + }, [rowData, severityText]); + + const handleClick = useCallback(() => { + if (result) { + setIsOpen(prev => !prev); + return; + } + if (aiEnabled) { + handleRealAI(); + } else { + handleFakeAI(); + } + }, [result, aiEnabled, handleRealAI, handleFakeAI]); const handleRegenerate = useCallback(() => { - setIsGenerating(true); - timerRef.current = setTimeout(() => { - setResult(generateSummary(rowData ?? {}, severityText)); - setIsGenerating(false); - timerRef.current = null; - }, 1200); - }, [rowData, severityText]); + 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(); + if (!aiEnabled) { + dismissEasterEgg(); + } setIsOpen(false); - // Let Collapse animate closed before unmounting. setTimeout(() => setDismissed(true), 300); - }, []); + }, [aiEnabled]); + + const handleToneChange = useCallback( + (t: AISummarizeTone) => { + setTone(t); + saveTone(t); + setResult(null); + handleRealAI(t); + }, + [handleRealAI], + ); - if (dismissed || !isEasterEggVisible()) 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 ( ); } diff --git a/packages/app/src/components/AISummarizePatternButton.tsx b/packages/app/src/components/AISummarizePatternButton.tsx index 39ed412cca..e4b5372876 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, @@ -9,17 +10,17 @@ import { import AISummaryPanel from './aiSummarize/AISummaryPanel'; import { + AISummarizeTone, dismissEasterEgg, generatePatternSummary, + getSavedTone, isEasterEggVisible, + isSmartMode, RowData, + saveTone, 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 +32,62 @@ 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], }; } +// 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, +): 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}`); + + // 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.startsWith('__hdx_') && + 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(', ')}`, + ); + } + } + } + } + + return parts.join('\n'); +} + export default function AISummarizePatternButton({ pattern, serviceNameExpression, @@ -45,38 +95,65 @@ export default function AISummarizePatternButton({ pattern: Pattern; serviceNameExpression: string; }) { + const { data: me } = api.useMe(); + const aiEnabled = me?.aiAssistantEnabled ?? false; + const showEasterEgg = isEasterEggVisible(); + const smartMode = isSmartMode(); + 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 [tone, setTone] = useState(getSavedTone); 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( + (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, tone], + ); + + const handleFakeAI = useCallback(() => { setIsGenerating(true); setIsOpen(true); const { rowData, severityText } = buildRowDataFromSample( @@ -95,36 +172,66 @@ export default function AISummarizePatternButton({ setIsGenerating(false); timerRef.current = null; }, 1800); - }, [pattern, serviceNameExpression, result]); + }, [pattern, serviceNameExpression]); + + const handleClick = useCallback(() => { + if (result) { + setIsOpen(prev => !prev); + return; + } + if (aiEnabled) { + handleRealAI(); + } else { + handleFakeAI(); + } + }, [result, aiEnabled, handleRealAI, handleFakeAI]); const handleRegenerate = useCallback(() => { - setIsGenerating(true); - const { rowData, severityText } = buildRowDataFromSample( - pattern, - serviceNameExpression, - ); - timerRef.current = setTimeout(() => { - setResult( - generatePatternSummary( - pattern.pattern, - pattern.count, - rowData, - severityText, - ), + setResult(null); + setError(null); + if (aiEnabled) { + handleRealAI(); + } else { + setIsGenerating(true); + const { rowData, severityText } = buildRowDataFromSample( + pattern, + serviceNameExpression, ); - setIsGenerating(false); - timerRef.current = null; - }, 1200); - }, [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(); + if (!aiEnabled) { + dismissEasterEgg(); + } setIsOpen(false); - // Let Collapse animate closed before unmounting. setTimeout(() => setDismissed(true), 300); - }, []); + }, [aiEnabled]); + + const handleToneChange = useCallback( + (t: AISummarizeTone) => { + setTone(t); + saveTone(t); + setResult(null); + handleRealAI(t); + }, + [handleRealAI], + ); - if (dismissed || !isEasterEggVisible()) return null; + if (dismissed) return null; + if (!aiEnabled && !showEasterEgg) return null; return ( ); } diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 6119936c85..fa25314f5d 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -329,6 +329,10 @@ const DBRowSidePanel = ({ rowData={normalizedRow} breadcrumbPath={breadcrumbPath} onBreadcrumbClick={handleBreadcrumbClick} + traceId={traceId} + traceSourceId={traceSourceId} + dateRange={oneHourRange} + focusDate={focusDate} /> {/* ; breadcrumbPath?: BreadcrumbPath; onBreadcrumbClick?: BreadcrumbNavigationCallback; + traceId?: string; + traceSourceId?: string | null; + dateRange?: [Date, Date]; + focusDate?: Date; }) { const [bodyExpanded, setBodyExpanded] = React.useState(false); const { onPropertyAddClick, generateSearchUrl } = @@ -283,7 +291,14 @@ export default function DBRowSidePanelHeader({ )} - + diff --git a/packages/app/src/components/__tests__/AISummarize.test.tsx b/packages/app/src/components/__tests__/AISummarize.test.tsx new file mode 100644 index 0000000000..5a10f1bdb9 --- /dev/null +++ b/packages/app/src/components/__tests__/AISummarize.test.tsx @@ -0,0 +1,574 @@ +import React from 'react'; +import { act, fireEvent, screen } from '@testing-library/react'; + +import type { Pattern } from '@/hooks/usePatterns'; + +import { + default as AISummarizeButton, + formatEventContent, +} from '../AISummarizeButton'; +import { + default as AISummarizePatternButton, + formatPatternContent, +} from '../AISummarizePatternButton'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockMutate = jest.fn(); +jest.mock('@/hooks/ai', () => ({ + 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 }), + }, +})); + +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'); + 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 => ({ + id: 'pat-1', + 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}`, + __hdx_timestamp: `2026-01-01T00:00:0${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( + expect.objectContaining({ + 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 = { + 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', + }, + ], + }; + + 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( + expect.objectContaining({ + 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 1de7a3e7d9..9978edbd6c 100644 --- a/packages/app/src/components/aiSummarize/AISummaryPanel.tsx +++ b/packages/app/src/components/aiSummarize/AISummaryPanel.tsx @@ -1,5 +1,5 @@ -// Easter egg: April Fools 2026 — shared presentational component for AI Summarize. import { useState } from 'react'; +import Markdown from 'react-markdown'; import { Anchor, Button, @@ -11,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({ @@ -21,14 +23,22 @@ export default function AISummaryPanel({ onRegenerate, onDismiss, analyzingLabel = 'Analyzing event data...', + isRealAI = false, + error, + tone, + onToneChange, }: { 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; + tone?: AISummarizeTone; + onToneChange?: (tone: AISummarizeTone) => void; }) { const [infoOpen, setInfoOpen] = useState(false); @@ -54,6 +64,47 @@ export default function AISummaryPanel({ Regenerate )} + {onToneChange && isOpen && ( + + )} + {onDismiss && !isOpen && ( + + Don't show + + )} @@ -70,6 +120,10 @@ export default function AISummaryPanel({ {analyzingLabel} + ) : error ? ( + + {error} + ) : ( <> @@ -83,54 +137,98 @@ 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 && ( + + + 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. + + {onDismiss && ( + { + setInfoOpen(false); + onDismiss(); + }} + style={{ cursor: 'pointer' }} + > + Don't show again + + )} + + + )} - - {result?.text} - + {isRealAI ? ( +
+ ( +

{children}

+ ), + strong: ({ children }) => ( + + {children} + + ), + code: ({ children }) => ( + + {children} + + ), + }} + > + {result?.text ?? ''} +
+
+ ) : ( + + {result?.text} + + )} )}
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..ef32df6bec 100644 --- a/packages/app/src/components/aiSummarize/index.ts +++ b/packages/app/src/components/aiSummarize/index.ts @@ -1,5 +1,13 @@ // 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 { 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..434109b8a0 --- /dev/null +++ b/packages/app/src/components/aiSummarize/traceContext.ts @@ -0,0 +1,125 @@ +// 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.min(Math.max(0, idx), sorted.length - 1)]; +} + +function fmtMs(ms: number): string { + if (ms < 1) return '<1ms'; + if (ms < 1000) return `${Math.round(ms)}ms`; + 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 ''; + + 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`); + } + } + + 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; +} diff --git a/packages/app/src/hooks/ai.ts b/packages/app/src/hooks/ai.ts index 177a1aba3c..70547a1b23 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,23 @@ export function useChartAssistant() { }).json(), }); } + +type SummarizeInput = { + type: 'event' | 'pattern'; + content: string; + tone?: 'default' | 'noir' | 'attenborough' | 'shakespeare'; +}; + +type SummarizeResponse = { + summary: string; +}; + +export function useAISummarize() { + return useMutation({ + mutationFn: async ({ type, content, tone }: SummarizeInput) => + hdxServer('ai/summarize', { + method: 'POST', + json: { type, content, ...(tone && tone !== 'default' && { tone }) }, + }).json(), + }); +}