diff --git a/packages/cli/e2e/__tests__/help.spec.ts b/packages/cli/e2e/__tests__/help.spec.ts index 8bd19e0e5..c045e51f0 100644 --- a/packages/cli/e2e/__tests__/help.spec.ts +++ b/packages/cli/e2e/__tests__/help.spec.ts @@ -60,6 +60,7 @@ describe('help', () => { incidents Create and manage status page incidents. login Login to your Checkly account or create a new one. logout Log out and clear any local credentials. + rca Trigger and retrieve root cause analyses. rules Generate a rules file to use with AI IDEs and Copilots. runtimes List all supported runtimes and dependencies. skills Show Checkly AI skills, actions and their references. diff --git a/packages/cli/package.json b/packages/cli/package.json index 29c2a8957..910072752 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -79,6 +79,9 @@ "import": { "description": "Import existing resources from your Checkly account to your project." }, + "rca": { + "description": "Trigger and retrieve root cause analyses." + }, "status-pages": { "description": "List and manage status pages in your Checkly account." } diff --git a/packages/cli/src/commands/__tests__/init.spec.ts b/packages/cli/src/commands/__tests__/init.spec.ts index a63ba0c30..621f94dde 100644 --- a/packages/cli/src/commands/__tests__/init.spec.ts +++ b/packages/cli/src/commands/__tests__/init.spec.ts @@ -1,3 +1,4 @@ +import { join } from 'path' import { describe, it, expect, vi, beforeEach } from 'vitest' import Init from '../init' @@ -143,7 +144,7 @@ describe('Init command', () => { await cmd.run() expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith( - '/tmp/My App/package.json', + join('/tmp/My App', 'package.json'), expect.stringContaining('"name": "my-app"'), ) } finally { diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 37c3a1eb0..7483a8d2e 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -11,6 +11,7 @@ import { formatErrorGroups, } from '../../formatters/checks' import { formatResultDetail } from '../../formatters/check-result-detail' +import { formatRcaDetail, formatRcaHint, transformErrorGroupForJson } from '../../formatters/rca' import { quickRangeValues, type QuickRange, type GroupBy } from '../../rest/analytics' import { formatAnalyticsSection } from '../../formatters/analytics' @@ -102,7 +103,8 @@ export default class ChecksGet extends AuthCommand { if (flags.output === 'json') { const analytics = analyticsResp ?? null - this.log(JSON.stringify({ check, status, results, nextId, errorGroups, analytics }, null, 2)) + const errorGroupsJson = errorGroups.map(transformErrorGroupForJson) + this.log(JSON.stringify({ check, status, results, nextId, errorGroups: errorGroupsJson, analytics }, null, 2)) return } @@ -190,7 +192,7 @@ export default class ChecksGet extends AuthCommand { ]) if (outputFormat === 'json') { - this.log(JSON.stringify(errorGroup, null, 2)) + this.log(JSON.stringify(transformErrorGroupForJson(errorGroup), null, 2)) return } @@ -225,6 +227,12 @@ export default class ChecksGet extends AuthCommand { if (check.scriptPath) { lines.push(`| Source file | ${check.scriptPath} |`) } + const rcasMd = errorGroup.rootCauseAnalyses ?? [] + if (rcasMd.length > 0) { + lines.push('', formatRcaDetail(rcasMd[0], fmt)) + const hintMd = formatRcaHint(rcasMd.length, fmt) + if (hintMd) lines.push('', hintMd) + } this.log(lines.join('\n')) return } @@ -250,6 +258,14 @@ export default class ChecksGet extends AuthCommand { output.push(`${chalk.dim('Source file:')} ${chalk.cyan(check.scriptPath)}`) } + const rcas = errorGroup.rootCauseAnalyses ?? [] + if (rcas.length > 0) { + output.push('') + output.push(formatRcaDetail(rcas[0], fmt)) + const hint = formatRcaHint(rcas.length, fmt) + if (hint) output.push('', hint) + } + output.push('') output.push(` ${chalk.dim('Back to check:')} checkly checks get ${checkId}`) output.push(` ${chalk.dim('Back to list:')} checkly checks list`) diff --git a/packages/cli/src/commands/rca/get.ts b/packages/cli/src/commands/rca/get.ts new file mode 100644 index 000000000..5288ffade --- /dev/null +++ b/packages/cli/src/commands/rca/get.ts @@ -0,0 +1,76 @@ +import { Args, Flags } from '@oclif/core' +import { AuthCommand } from '../authCommand' +import { outputFlag } from '../../helpers/flags' +import * as api from '../../rest/api' +import { formatRcaCompleted } from '../../formatters/rca' + +export default class RcaGet extends AuthCommand { + static hidden = false + static readOnly = true + static idempotent = true + static description = 'Retrieve a root cause analysis by ID.' + + static args = { + id: Args.string({ + description: 'The RCA ID to retrieve.', + required: true, + }), + } + + static flags = { + watch: Flags.boolean({ + char: 'w', + description: 'Wait for the analysis to complete if still generating.', + default: false, + }), + output: outputFlag({ default: 'detail' }), + } + + async run (): Promise { + const { args, flags } = await this.parse(RcaGet) + this.style.outputFormat = flags.output + + try { + // Fetch the RCA — 202 means still generating, 200 means complete + const response = await api.rca.get(args.id) + + if (response.status !== 202) { + const fmt = flags.output === 'json' ? 'json' : flags.output === 'md' ? 'md' : 'terminal' + this.log(formatRcaCompleted(response.data, fmt)) + return + } + + // Still generating + if (!flags.watch) { + if (flags.output === 'json') { + this.log(JSON.stringify({ id: args.id, status: 'pending' }, null, 2)) + } else { + this.log('Root cause analysis is still being generated.') + this.log(`Use ${this.config.bin} rca get ${args.id} --watch to wait for completion.`) + } + return + } + + if (flags.output !== 'detail') { + process.stderr.write(`--watch is not supported with --output ${flags.output}, ignoring\n`) + if (flags.output === 'json') { + this.log(JSON.stringify({ id: args.id, status: 'pending' }, null, 2)) + } else { + this.log('Root cause analysis is still being generated.') + } + return + } + + // Watch mode: poll until complete + this.style.actionStart('Waiting for root cause analysis...') + + const rca = await api.rca.pollUntilComplete(args.id) + + this.style.actionSuccess() + this.log(formatRcaCompleted(rca, 'terminal')) + } catch (err: any) { + this.style.longError('Failed to retrieve root cause analysis.', err) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/commands/rca/run.ts b/packages/cli/src/commands/rca/run.ts new file mode 100644 index 000000000..e5acfaf68 --- /dev/null +++ b/packages/cli/src/commands/rca/run.ts @@ -0,0 +1,84 @@ +import { Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand' +import { outputFlag } from '../../helpers/flags' +import * as api from '../../rest/api' +import { NotFoundError, InadequateEntitlementsError } from '../../rest/errors' +import { formatRcaPending, formatRcaCompleted } from '../../formatters/rca' + +export default class RcaRun extends AuthCommand { + static hidden = false + static readOnly = false + static idempotent = false + static description = 'Trigger a root cause analysis for an error group.' + + static flags = { + 'error-group': Flags.string({ + char: 'e', + description: 'The error group ID to analyze.', + required: true, + }), + 'watch': Flags.boolean({ + char: 'w', + description: 'Wait for the analysis to complete and display the result.', + default: false, + }), + 'output': outputFlag({ default: 'detail' }), + } + + async run (): Promise { + const { flags } = await this.parse(RcaRun) + this.style.outputFormat = flags.output + + try { + // Fetch the error group to get the checkId for navigation hints + const { data: errorGroup } = await api.errorGroups.get(flags['error-group']) + + // Trigger the RCA + const { data: { id: rcaId } } = await api.rca.trigger(flags['error-group']) + + const pendingInfo = { + rcaId, + errorGroupId: flags['error-group'], + checkId: errorGroup.checkId, + } + + // If not watching, show pending state and exit + if (!flags.watch || flags.output === 'json' || flags.output === 'md') { + if (flags.watch && flags.output !== 'detail') { + process.stderr.write(`--watch is not supported with --output ${flags.output}, ignoring\n`) + } + const fmt = flags.output === 'json' ? 'json' : flags.output === 'md' ? 'md' : 'terminal' + this.log(formatRcaPending(pendingInfo, fmt)) + return + } + + // Watch mode: poll until complete + this.log(chalk.bold('Root cause analysis triggered.')) + this.log(`${chalk.dim('RCA ID:')} ${rcaId}`) + this.log('') + this.style.actionStart('Waiting for root cause analysis...') + + const rca = await api.rca.pollUntilComplete(rcaId) + + this.style.actionSuccess() + this.log(formatRcaCompleted(rca, 'terminal')) + } catch (err: any) { + if (err instanceof InadequateEntitlementsError) { + this.style.longError( + 'Root cause analysis is not available on your current plan.', + 'Run `checkly account plan` to check your entitlements.', + ) + process.exitCode = 1 + return + } + if (err instanceof NotFoundError) { + this.style.shortError(`Error group not found: ${flags['error-group']}`) + process.exitCode = 1 + return + } + this.style.longError('Failed to trigger root cause analysis.', err) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts index a0a40f860..e573155fe 100644 --- a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts +++ b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts @@ -1,7 +1,7 @@ import type { Check } from '../../rest/checks' import type { CheckStatus } from '../../rest/check-statuses' import type { CheckResult, ApiCheckResult, BrowserCheckResult, MultiStepCheckResult } from '../../rest/check-results' -import type { ErrorGroup } from '../../rest/error-groups' +import type { ErrorGroup, RootCauseAnalysis } from '../../rest/error-groups' import type { CheckWithStatus } from '../checks' // --- Check statuses --- @@ -388,3 +388,75 @@ export const archivedErrorGroup: ErrorGroup = { lastSeen: '2025-05-15T00:00:00.000Z', archivedUntilNextEvent: true, } + +// --- Root cause analyses --- + +export const sampleRca: RootCauseAnalysis = { + id: 'rca-1', + created_at: '2025-06-15T10:00:00.000Z', + analysis: { + classification: 'INFRASTRUCTURE_ERROR', + rootCause: 'The upstream API returned HTTP 503 Service Unavailable after a long server processing time (~28s TTFB), indicating a transient backend issue.', + userImpact: 'Users in ap-south-1 cannot trigger checks via the API. Requests fail with 503 after ~28 seconds.', + codeFix: 'Add retry logic with exponential backoff for transient 503 responses.', + evidence: [ + { + artifacts: [{ name: 'HTTP_REQUEST', type: 'REQUEST' }], + description: 'The HTTP request completed with status 503 Service Unavailable.', + }, + { + artifacts: [{ name: 'TIMING_PHASES', type: 'TIMINGS' }], + description: 'DNS and TCP times are sub-2ms while TTFB is ~28.2s.', + }, + { + artifacts: [ + { name: 'TRACE_ROUTE', type: 'TRACE' }, + { name: 'PACKET_CAPTURE', type: 'BINARY' }, + ], + description: 'No sustained network outage; the failure is on the application side.', + }, + ], + referenceLinks: [ + { url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503', title: 'HTTP 503 Service Unavailable' }, + ], + }, + provider: 'openai', + model: 'gpt-5.1', + durationMs: 9459, + userContext: [{ text: 'checkly-backend', type: 'TAG' }], +} + +export const sampleRcaMinimal: RootCauseAnalysis = { + id: 'rca-2', + created_at: '2025-06-14T08:00:00.000Z', + analysis: { + classification: 'APPLICATION_ERROR', + rootCause: 'The API endpoint /users returns 404 because the route was removed in a recent deployment.', + userImpact: 'User profile pages fail to load.', + codeFix: null, + evidence: null, + referenceLinks: null, + }, + provider: 'openai', + model: 'gpt-5.1', + durationMs: 5000, + userContext: null, +} + +export const errorGroupWithRca: ErrorGroup = { + ...activeErrorGroup, + id: 'eg-rca-1', + rootCauseAnalyses: [sampleRca], +} + +export const errorGroupWithMultipleRcas: ErrorGroup = { + ...activeErrorGroup, + id: 'eg-rca-2', + rootCauseAnalyses: [sampleRca, sampleRcaMinimal], +} + +export const errorGroupWithoutRca: ErrorGroup = { + ...activeErrorGroup, + id: 'eg-no-rca', + rootCauseAnalyses: [], +} diff --git a/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap index b250427ec..4c6c0d108 100644 --- a/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap +++ b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap @@ -77,15 +77,17 @@ Muted Check API passing 10m production, api exports[`formatErrorGroups > renders markdown error groups > error-groups-md 1`] = ` "## Error Groups -| Error | First Seen | Last Seen | ID | -| --- | --- | --- | --- | -| TimeoutError: page.click: Timeout 30000ms exceeded | 2025-06-01T00:00:00.000Z | 2025-06-15T12:00:00.000Z | eg-1 |" +| Error | First Seen | Last Seen | RCA | ID | +| --- | --- | --- | --- | --- | +| TimeoutError: page.click: Timeout 30000ms exceeded | 2025-06-01T00:00:00.000Z | 2025-06-15T12:00:00.000Z | - | eg-1 |" `; exports[`formatErrorGroups > renders terminal error groups > error-groups-terminal 1`] = ` "ERROR GROUPS -ERROR FIRST SEEN LAST SEEN -TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago" +ERROR FIRST SEEN LAST SEEN RCA ERROR GROUP ID +TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago - eg-1 + + Run root cause analysis: checkly rca run -e eg-1 -w" `; exports[`formatResults > renders markdown table > results-table-md 1`] = ` diff --git a/packages/cli/src/formatters/__tests__/checks.spec.ts b/packages/cli/src/formatters/__tests__/checks.spec.ts index bade1e3c5..8f8d0f022 100644 --- a/packages/cli/src/formatters/__tests__/checks.spec.ts +++ b/packages/cli/src/formatters/__tests__/checks.spec.ts @@ -31,6 +31,8 @@ import { browserCheckResult, activeErrorGroup, archivedErrorGroup, + errorGroupWithRca, + errorGroupWithoutRca, } from './__fixtures__/fixtures' // Pin time for timeAgo used in results/error groups @@ -361,4 +363,27 @@ describe('formatErrorGroups', () => { it('returns empty string for empty array', () => { expect(formatErrorGroups([], 'terminal')).toBe('') }) + + it('shows RCA column with Yes for error groups with RCA', () => { + const result = stripAnsi(formatErrorGroups([errorGroupWithRca], 'terminal')) + expect(result).toContain('RCA') + expect(result).toContain('Yes') + }) + + it('shows RCA column with dash for error groups without RCA', () => { + const result = stripAnsi(formatErrorGroups([errorGroupWithoutRca], 'terminal')) + expect(result).toContain('RCA') + expect(result).toContain('-') + }) + + it('shows RCA column in markdown', () => { + const result = formatErrorGroups([errorGroupWithRca], 'md') + expect(result).toContain('| RCA |') + expect(result).toContain('Yes') + }) + + it('shows dash in markdown for error groups without RCA', () => { + const result = formatErrorGroups([errorGroupWithoutRca], 'md') + expect(result).toContain('| - |') + }) }) diff --git a/packages/cli/src/formatters/__tests__/rca.spec.ts b/packages/cli/src/formatters/__tests__/rca.spec.ts new file mode 100644 index 000000000..3f8ba6990 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/rca.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest' +import { stripAnsi } from '../render' +import { formatRcaDetail, formatRcaPending, formatRcaCompleted, transformErrorGroupForJson } from '../rca' +import { + sampleRca, + sampleRcaMinimal, + errorGroupWithRca, + errorGroupWithMultipleRcas, + errorGroupWithoutRca, +} from './__fixtures__/fixtures' + +describe('formatRcaDetail', () => { + describe('terminal', () => { + it('renders classification, root cause, and user impact', () => { + const result = stripAnsi(formatRcaDetail(sampleRca, 'terminal')) + expect(result).toContain('ROOT CAUSE ANALYSIS') + expect(result).toContain('INFRASTRUCTURE_ERROR') + expect(result).toContain('upstream API returned HTTP 503') + expect(result).toContain('Users in ap-south-1') + }) + + it('renders code fix when present', () => { + const result = stripAnsi(formatRcaDetail(sampleRca, 'terminal')) + expect(result).toContain('Code fix:') + expect(result).toContain('retry logic') + }) + + it('omits code fix when null', () => { + const result = stripAnsi(formatRcaDetail(sampleRcaMinimal, 'terminal')) + expect(result).not.toContain('Code fix:') + }) + + it('renders evidence section', () => { + const result = stripAnsi(formatRcaDetail(sampleRca, 'terminal')) + expect(result).toContain('EVIDENCE') + expect(result).toContain('HTTP_REQUEST') + expect(result).toContain('TIMING_PHASES') + expect(result).toContain('TRACE_ROUTE') + expect(result).toContain('completed with status 503') + }) + + it('omits evidence section when null', () => { + const result = stripAnsi(formatRcaDetail(sampleRcaMinimal, 'terminal')) + expect(result).not.toContain('EVIDENCE') + }) + + it('renders reference links', () => { + const result = stripAnsi(formatRcaDetail(sampleRca, 'terminal')) + expect(result).toContain('REFERENCES') + expect(result).toContain('HTTP 503 Service Unavailable') + expect(result).toContain('https://developer.mozilla.org') + }) + + it('omits references when null', () => { + const result = stripAnsi(formatRcaDetail(sampleRcaMinimal, 'terminal')) + expect(result).not.toContain('REFERENCES') + }) + }) + + describe('markdown', () => { + it('renders RCA as markdown headings', () => { + const result = formatRcaDetail(sampleRca, 'md') + expect(result).toContain('## Root Cause Analysis') + expect(result).toContain('**Classification:**') + expect(result).toContain('INFRASTRUCTURE_ERROR') + }) + + it('renders code fix when present', () => { + const result = formatRcaDetail(sampleRca, 'md') + expect(result).toContain('**Code Fix:**') + expect(result).toContain('retry logic') + }) + + it('renders evidence as bullet list', () => { + const result = formatRcaDetail(sampleRca, 'md') + expect(result).toContain('### Evidence') + expect(result).toContain('- **HTTP_REQUEST') + }) + + it('renders reference links as markdown links', () => { + const result = formatRcaDetail(sampleRca, 'md') + expect(result).toContain('### References') + expect(result).toContain('[HTTP 503 Service Unavailable]') + }) + }) +}) + +describe('transformErrorGroupForJson', () => { + it('transforms latest RCA into latestRootCauseAnalysis', () => { + const result = transformErrorGroupForJson(errorGroupWithRca) + expect(result.latestRootCauseAnalysis).toBeDefined() + expect(result.latestRootCauseAnalysis!.id).toBe('rca-1') + expect(result.latestRootCauseAnalysis!.classification).toBe('INFRASTRUCTURE_ERROR') + expect(result.rootCauseAnalysisCount).toBe(1) + }) + + it('picks first entry (most recent) when multiple exist', () => { + const result = transformErrorGroupForJson(errorGroupWithMultipleRcas) + expect(result.latestRootCauseAnalysis!.id).toBe('rca-1') + expect(result.rootCauseAnalysisCount).toBe(2) + }) + + it('returns null and count 0 when no RCA', () => { + const result = transformErrorGroupForJson(errorGroupWithoutRca) + expect(result.latestRootCauseAnalysis).toBeNull() + expect(result.rootCauseAnalysisCount).toBe(0) + }) + + it('strips rootCauseAnalyses array from output', () => { + const result = transformErrorGroupForJson(errorGroupWithRca) + expect(result).not.toHaveProperty('rootCauseAnalyses') + }) + + it('flattens analysis fields into latestRootCauseAnalysis', () => { + const result = transformErrorGroupForJson(errorGroupWithRca) + const rca = result.latestRootCauseAnalysis! + expect(rca).toHaveProperty('classification') + expect(rca).toHaveProperty('rootCause') + expect(rca).toHaveProperty('userImpact') + expect(rca).toHaveProperty('codeFix') + expect(rca).toHaveProperty('evidence') + expect(rca).toHaveProperty('referenceLinks') + expect(rca).toHaveProperty('provider') + expect(rca).toHaveProperty('model') + expect(rca).toHaveProperty('durationMs') + expect(rca).toHaveProperty('created_at') + }) +}) + +describe('formatRcaPending', () => { + const pendingInfo = { + rcaId: 'rca-123', + errorGroupId: 'eg-456', + checkId: 'check-789', + } + + it('renders pending state in terminal', () => { + const result = stripAnsi(formatRcaPending(pendingInfo, 'terminal')) + expect(result).toContain('Root cause analysis triggered') + expect(result).toContain('rca-123') + expect(result).toContain('eg-456') + expect(result).toContain('pending') + expect(result).toContain('checkly rca get rca-123 --watch') + expect(result).toContain('checkly checks get check-789 --error-group eg-456') + }) + + it('renders pending state in json', () => { + const result = formatRcaPending(pendingInfo, 'json') + const parsed = JSON.parse(result) + expect(parsed.id).toBe('rca-123') + expect(parsed.status).toBe('pending') + expect(parsed.errorGroupId).toBe('eg-456') + }) + + it('renders pending state in markdown', () => { + const result = formatRcaPending(pendingInfo, 'md') + expect(result).toContain('# Root Cause Analysis') + expect(result).toContain('pending') + expect(result).toContain('rca-123') + }) +}) + +describe('formatRcaCompleted', () => { + it('renders completed RCA in terminal', () => { + const result = stripAnsi(formatRcaCompleted(sampleRca, 'terminal')) + expect(result).toContain('ROOT CAUSE ANALYSIS') + expect(result).toContain('INFRASTRUCTURE_ERROR') + }) + + it('renders completed RCA in json', () => { + const result = formatRcaCompleted(sampleRca, 'json') + const parsed = JSON.parse(result) + expect(parsed.id).toBe('rca-1') + expect(parsed.analysis.classification).toBe('INFRASTRUCTURE_ERROR') + }) + + it('renders completed RCA in markdown', () => { + const result = formatRcaCompleted(sampleRca, 'md') + expect(result).toContain('## Root Cause Analysis') + expect(result).toContain('INFRASTRUCTURE_ERROR') + }) +}) diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts index f3acfa647..a559cd741 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -321,6 +321,7 @@ function buildErrorGroupColumns (format: OutputFormat): ColumnDef[] }, { header: 'First Seen', value: eg => eg.firstSeen }, { header: 'Last Seen', value: eg => eg.lastSeen }, + { header: 'RCA', value: eg => (eg.rootCauseAnalyses?.length ?? 0) > 0 ? 'Yes' : '-' }, { header: 'ID', value: eg => eg.id }, ] } @@ -338,8 +339,15 @@ function buildErrorGroupColumns (format: OutputFormat): ColumnDef[] }, { header: 'Last Seen', + width: 14, value: eg => chalk.dim(timeAgo(eg.lastSeen)), }, + { + header: 'RCA', + width: 6, + value: eg => (eg.rootCauseAnalyses?.length ?? 0) > 0 ? chalk.cyan('Yes') : chalk.dim('-'), + }, + { header: 'Error Group ID', value: eg => chalk.dim(eg.id) }, ] } @@ -351,5 +359,17 @@ export function formatErrorGroups (errorGroups: ErrorGroup[], format: OutputForm const title = format === 'md' ? '## Error Groups\n\n' : chalk.bold('ERROR GROUPS') + '\n' - return title + renderTable(columns, active, format) + const table = title + renderTable(columns, active, format) + + if (format !== 'terminal') return table + + const withoutRca = active.filter(eg => (eg.rootCauseAnalyses?.length ?? 0) === 0) + if (withoutRca.length === 0) return table + + const hint = withoutRca.length === 1 + ? `\n\n ${chalk.dim('Run root cause analysis:')} checkly rca run -e ${withoutRca[0].id} -w` + : `\n\n ${chalk.dim('Run root cause analysis on an error group without one:')}\n` + + withoutRca.map(eg => ` checkly rca run -e ${eg.id} -w`).join('\n') + + return table + hint } diff --git a/packages/cli/src/formatters/rca.ts b/packages/cli/src/formatters/rca.ts new file mode 100644 index 000000000..3f015d0b4 --- /dev/null +++ b/packages/cli/src/formatters/rca.ts @@ -0,0 +1,189 @@ +import chalk from 'chalk' +import type { RootCauseAnalysis, ErrorGroup } from '../rest/error-groups' +import { type OutputFormat, heading } from './render' + +function label (text: string, width = 16): string { + return chalk.dim(text.padEnd(width)) +} + +export function formatRcaDetail (rca: RootCauseAnalysis, format: OutputFormat): string { + if (format === 'md') { + return formatRcaMd(rca) + } + return formatRcaTerminal(rca) +} + +function formatRcaTerminal (rca: RootCauseAnalysis): string { + const a = rca.analysis + const lines: string[] = [] + + lines.push(heading('ROOT CAUSE ANALYSIS', 1, 'terminal')) + lines.push('') + lines.push(`${label('Classification:')}${chalk.cyan(a.classification)}`) + lines.push(`${label('Root cause:')}${a.rootCause}`) + lines.push(`${label('User impact:')}${a.userImpact}`) + + if (a.codeFix) { + lines.push(`${label('Code fix:')}${a.codeFix}`) + } + + if (a.evidence && a.evidence.length > 0) { + lines.push('') + lines.push(` ${heading('EVIDENCE', 2, 'terminal')}`) + for (const e of a.evidence) { + const artifacts = e.artifacts.map(ar => `${ar.name} (${ar.type})`).join(', ') + lines.push(` ${chalk.dim('·')} ${chalk.bold(artifacts)} — ${e.description}`) + } + } + + if (a.referenceLinks && a.referenceLinks.length > 0) { + lines.push('') + lines.push(` ${heading('REFERENCES', 2, 'terminal')}`) + for (const ref of a.referenceLinks) { + lines.push(` ${chalk.dim('·')} ${ref.title} ${chalk.dim(ref.url)}`) + } + } + + return lines.join('\n') +} + +function formatRcaMd (rca: RootCauseAnalysis): string { + const a = rca.analysis + const lines: string[] = [] + + lines.push('## Root Cause Analysis') + lines.push('') + lines.push(`**Classification:** ${a.classification}`) + lines.push('') + lines.push(`**Root Cause:** ${a.rootCause}`) + lines.push('') + lines.push(`**User Impact:** ${a.userImpact}`) + + if (a.codeFix) { + lines.push('') + lines.push(`**Code Fix:** ${a.codeFix}`) + } + + if (a.evidence && a.evidence.length > 0) { + lines.push('') + lines.push('### Evidence') + for (const e of a.evidence) { + const artifacts = e.artifacts.map(ar => `${ar.name} (${ar.type})`).join(', ') + lines.push(`- **${artifacts}** — ${e.description}`) + } + } + + if (a.referenceLinks && a.referenceLinks.length > 0) { + lines.push('') + lines.push('### References') + for (const ref of a.referenceLinks) { + lines.push(`- [${ref.title}](${ref.url})`) + } + } + + return lines.join('\n') +} + +export function formatRcaHint (count: number, format: OutputFormat): string { + if (count <= 1) return '' + const more = count - 1 + const text = `(${more} more ${more === 1 ? 'analysis' : 'analyses'} available)` + return format === 'terminal' ? chalk.dim(text) : `*${text}*` +} + +export interface ErrorGroupJsonOutput { + id: string + checkId: string + errorHash: string + rawErrorMessage: string | null + cleanedErrorMessage: string + firstSeen: string + lastSeen: string + archivedUntilNextEvent: boolean + latestRootCauseAnalysis: { + id: string + classification: string + rootCause: string + userImpact: string + codeFix: string | null + evidence: RootCauseAnalysis['analysis']['evidence'] + referenceLinks: RootCauseAnalysis['analysis']['referenceLinks'] + provider: string + model: string + durationMs: number + created_at: string + } | null + rootCauseAnalysisCount: number +} + +export interface RcaPendingInfo { + rcaId: string + errorGroupId: string + checkId: string +} + +export function formatRcaPending (info: RcaPendingInfo, format: OutputFormat | 'json'): string { + if (format === 'json') { + return JSON.stringify({ + id: info.rcaId, + status: 'pending', + errorGroupId: info.errorGroupId, + }, null, 2) + } + + if (format === 'md') { + return [ + '# Root Cause Analysis', + '', + '| Field | Value |', + '| --- | --- |', + `| RCA ID | ${info.rcaId} |`, + `| Error group | ${info.errorGroupId} |`, + '| Status | pending |', + ].join('\n') + } + + const lines: string[] = [] + lines.push(chalk.bold('Root cause analysis triggered.')) + lines.push('') + lines.push(`${label('RCA ID:')}${info.rcaId}`) + lines.push(`${label('Error group:')}${info.errorGroupId}`) + lines.push(`${label('Status:')}${chalk.yellow('pending')}`) + lines.push('') + lines.push(` ${chalk.dim('Watch progress:')} checkly rca get ${info.rcaId} --watch`) + lines.push(` ${chalk.dim('View result:')} checkly checks get ${info.checkId} --error-group ${info.errorGroupId}`) + return lines.join('\n') +} + +export function formatRcaCompleted (rca: RootCauseAnalysis, format: OutputFormat | 'json'): string { + if (format === 'json') { + return JSON.stringify(rca, null, 2) + } + return formatRcaDetail(rca, format) +} + +export function transformErrorGroupForJson (errorGroup: ErrorGroup): ErrorGroupJsonOutput { + const { rootCauseAnalyses, ...rest } = errorGroup + const rcas = rootCauseAnalyses ?? [] + const latest = rcas[0] ?? null + + return { + ...rest, + latestRootCauseAnalysis: latest + ? { + id: latest.id, + classification: latest.analysis.classification, + rootCause: latest.analysis.rootCause, + userImpact: latest.analysis.userImpact, + codeFix: latest.analysis.codeFix, + evidence: latest.analysis.evidence, + referenceLinks: latest.analysis.referenceLinks, + provider: latest.provider, + model: latest.model, + durationMs: latest.durationMs, + created_at: latest.created_at, + } + : null, + rootCauseAnalysisCount: rcas.length, + } +} diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index f04b570b8..6e59ca848 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -23,6 +23,7 @@ import Incidents from './incidents' import Analytics from './analytics' import BatchAnalytics from './batch-analytics' import Entitlements from './entitlements' +import Rca from './rca' import { handleErrorResponse, UnauthorizedError } from './errors' import { detectOperator } from '../helpers/cli-mode' @@ -125,3 +126,4 @@ export const incidents = new Incidents(api) export const analytics = new Analytics(api) export const batchAnalytics = new BatchAnalytics(api) export const entitlements = new Entitlements(api) +export const rca = new Rca(api) diff --git a/packages/cli/src/rest/error-groups.ts b/packages/cli/src/rest/error-groups.ts index 50c386c19..6040de31f 100644 --- a/packages/cli/src/rest/error-groups.ts +++ b/packages/cli/src/rest/error-groups.ts @@ -1,5 +1,32 @@ import type { AxiosInstance } from 'axios' +export interface RcaEvidence { + artifacts: Array<{ name: string, type: string }> + description: string +} + +export interface RcaReferenceLink { + url: string + title: string +} + +export interface RootCauseAnalysis { + id: string + created_at: string + analysis: { + classification: string + rootCause: string + userImpact: string + codeFix: string | null + evidence: RcaEvidence[] | null + referenceLinks: RcaReferenceLink[] | null + } + provider: string + model: string + durationMs: number + userContext: Array<{ text: string, type: string }> | null +} + export interface ErrorGroup { id: string checkId: string @@ -9,6 +36,7 @@ export interface ErrorGroup { firstSeen: string lastSeen: string archivedUntilNextEvent: boolean + rootCauseAnalyses?: RootCauseAnalysis[] } class ErrorGroups { diff --git a/packages/cli/src/rest/rca.ts b/packages/cli/src/rest/rca.ts new file mode 100644 index 000000000..eb2762607 --- /dev/null +++ b/packages/cli/src/rest/rca.ts @@ -0,0 +1,38 @@ +import type { AxiosInstance } from 'axios' +import type { RootCauseAnalysis } from './error-groups' + +export interface TriggerRcaResponse { + id: string +} + +const POLL_INTERVAL_MS = 2000 + +class Rca { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + trigger (errorGroupId: string) { + return this.api.post( + `/v1/root-cause-analyses/error-groups/${errorGroupId}`, + ) + } + + get (id: string) { + return this.api.get(`/v1/root-cause-analyses/${id}`) + } + + async pollUntilComplete (id: string): Promise { + while (true) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + const response = await this.get(id) + if (response.status === 202) { + continue + } + return response.data + } + } +} + +export default Rca