From a9c7e905fd9cf92b790b82194e3e3b22dba36e15 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:31:06 +0100 Subject: [PATCH 01/17] feat: add RootCauseAnalysis types and test fixtures Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/__fixtures__/fixtures.ts | 74 ++++++++++++++++++- packages/cli/src/rest/error-groups.ts | 28 +++++++ 2 files changed, 101 insertions(+), 1 deletion(-) 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/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 { From 540f084081a629538930d56721710cc33293e7ee Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:31:55 +0100 Subject: [PATCH 02/17] feat: add RCA column to error groups table Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__snapshots__/checks.spec.ts.snap | 10 ++++---- .../src/formatters/__tests__/checks.spec.ts | 25 +++++++++++++++++++ packages/cli/src/formatters/checks.ts | 7 ++++++ 3 files changed, 37 insertions(+), 5 deletions(-) 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..8ebc7a77c 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,15 @@ 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 +TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago -" `; 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/checks.ts b/packages/cli/src/formatters/checks.ts index f3acfa647..5199e5fac 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,14 @@ 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('-'), + }, ] } From 1aeb80c4cf884bd4d23cce1ba640aee206a31a9b Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:32:52 +0100 Subject: [PATCH 03/17] feat: add RCA rendering module with terminal, markdown, and JSON support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/formatters/__tests__/rca.spec.ts | 128 ++++++++++++++++ packages/cli/src/formatters/rca.ts | 143 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 packages/cli/src/formatters/__tests__/rca.spec.ts create mode 100644 packages/cli/src/formatters/rca.ts 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..cc72b5203 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/rca.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { stripAnsi } from '../render' +import { formatRcaDetail, 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') + }) +}) diff --git a/packages/cli/src/formatters/rca.ts b/packages/cli/src/formatters/rca.ts new file mode 100644 index 000000000..54b9cde2f --- /dev/null +++ b/packages/cli/src/formatters/rca.ts @@ -0,0 +1,143 @@ +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 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, + } +} From 504b3f784da01911aae0a180263bf84f6b448867 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:50:29 +0100 Subject: [PATCH 04/17] feat: wire RCA into error group detail and JSON output Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/checks/get.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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`) From 4d0991430901721a930b6708377d569e19ecee58 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:31:06 +0100 Subject: [PATCH 05/17] feat: add RootCauseAnalysis types and test fixtures Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/__fixtures__/fixtures.ts | 74 ++++++++++++++++++- packages/cli/src/rest/error-groups.ts | 28 +++++++ 2 files changed, 101 insertions(+), 1 deletion(-) 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/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 { From 69579608712daf575ae62c8439e68b9673eb1049 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:31:55 +0100 Subject: [PATCH 06/17] feat: add RCA column to error groups table Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__snapshots__/checks.spec.ts.snap | 10 ++++---- .../src/formatters/__tests__/checks.spec.ts | 25 +++++++++++++++++++ packages/cli/src/formatters/checks.ts | 7 ++++++ 3 files changed, 37 insertions(+), 5 deletions(-) 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..8ebc7a77c 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,15 @@ 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 +TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago -" `; 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/checks.ts b/packages/cli/src/formatters/checks.ts index f3acfa647..5199e5fac 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,14 @@ 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('-'), + }, ] } From 9040b8aa94d566555f68d587f30028047e7f3c8f Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:32:52 +0100 Subject: [PATCH 07/17] feat: add RCA rendering module with terminal, markdown, and JSON support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/formatters/__tests__/rca.spec.ts | 128 ++++++++++++++++ packages/cli/src/formatters/rca.ts | 143 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 packages/cli/src/formatters/__tests__/rca.spec.ts create mode 100644 packages/cli/src/formatters/rca.ts 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..cc72b5203 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/rca.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { stripAnsi } from '../render' +import { formatRcaDetail, 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') + }) +}) diff --git a/packages/cli/src/formatters/rca.ts b/packages/cli/src/formatters/rca.ts new file mode 100644 index 000000000..54b9cde2f --- /dev/null +++ b/packages/cli/src/formatters/rca.ts @@ -0,0 +1,143 @@ +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 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, + } +} From c568a16a65be3a9e50c4389bfec09952f307cdae Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Fri, 27 Mar 2026 22:50:29 +0100 Subject: [PATCH 08/17] feat: wire RCA into error group detail and JSON output Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/checks/get.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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`) From e42c99b77200c118f70d330ee0e96ac391ca15d7 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 28 Mar 2026 00:17:17 +0100 Subject: [PATCH 09/17] feat: add Error Group ID column to error groups table Co-Authored-By: Claude Opus 4.6 (1M context) --- .../formatters/__tests__/__snapshots__/checks.spec.ts.snap | 4 ++-- packages/cli/src/formatters/checks.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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 8ebc7a77c..fb74a0a1a 100644 --- a/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap +++ b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap @@ -84,8 +84,8 @@ exports[`formatErrorGroups > renders markdown error groups > error-groups-md 1`] exports[`formatErrorGroups > renders terminal error groups > error-groups-terminal 1`] = ` "ERROR GROUPS -ERROR FIRST SEEN LAST SEEN RCA -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" `; exports[`formatResults > renders markdown table > results-table-md 1`] = ` diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts index 5199e5fac..c864f739e 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -347,6 +347,7 @@ function buildErrorGroupColumns (format: OutputFormat): ColumnDef[] width: 6, value: eg => (eg.rootCauseAnalyses?.length ?? 0) > 0 ? chalk.cyan('Yes') : chalk.dim('-'), }, + { header: 'Error Group ID', value: eg => chalk.dim(eg.id) }, ] } From a6614f3b53e4f1fb50521ab2ebec3fc34ac7f00e Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 28 Mar 2026 00:46:11 +0100 Subject: [PATCH 10/17] feat: add RCA API client for trigger and get endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/rest/api.ts | 2 ++ packages/cli/src/rest/rca.ts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 packages/cli/src/rest/rca.ts 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/rca.ts b/packages/cli/src/rest/rca.ts new file mode 100644 index 000000000..5c8b35e8c --- /dev/null +++ b/packages/cli/src/rest/rca.ts @@ -0,0 +1,25 @@ +import type { AxiosInstance } from 'axios' +import type { RootCauseAnalysis } from './error-groups' + +export interface TriggerRcaResponse { + id: string +} + +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}`) + } +} + +export default Rca From 920324fdf303d0fd9b45669b33287a27683d0ff1 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 28 Mar 2026 00:47:31 +0100 Subject: [PATCH 11/17] feat: add pending and completed RCA formatters Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/formatters/__tests__/rca.spec.ts | 56 ++++++++++++++++++- packages/cli/src/formatters/rca.ts | 46 +++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/formatters/__tests__/rca.spec.ts b/packages/cli/src/formatters/__tests__/rca.spec.ts index cc72b5203..3f8ba6990 100644 --- a/packages/cli/src/formatters/__tests__/rca.spec.ts +++ b/packages/cli/src/formatters/__tests__/rca.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { stripAnsi } from '../render' -import { formatRcaDetail, transformErrorGroupForJson } from '../rca' +import { formatRcaDetail, formatRcaPending, formatRcaCompleted, transformErrorGroupForJson } from '../rca' import { sampleRca, sampleRcaMinimal, @@ -126,3 +126,57 @@ describe('transformErrorGroupForJson', () => { 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/rca.ts b/packages/cli/src/formatters/rca.ts index 54b9cde2f..3f015d0b4 100644 --- a/packages/cli/src/formatters/rca.ts +++ b/packages/cli/src/formatters/rca.ts @@ -116,6 +116,52 @@ export interface ErrorGroupJsonOutput { 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 ?? [] From cd2b83987029101d49b5e851b853fc2d595a9f8f Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 28 Mar 2026 00:48:18 +0100 Subject: [PATCH 12/17] feat: add rca run command to trigger root cause analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/rca/run.ts | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 packages/cli/src/commands/rca/run.ts diff --git a/packages/cli/src/commands/rca/run.ts b/packages/cli/src/commands/rca/run.ts new file mode 100644 index 000000000..86e1bd610 --- /dev/null +++ b/packages/cli/src/commands/rca/run.ts @@ -0,0 +1,102 @@ +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' + +const POLL_INTERVAL_MS = 2000 + +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 this.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 + } + } + + private async pollUntilComplete (rcaId: string) { + while (true) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + try { + const { data } = await api.rca.get(rcaId) + return data + } catch (err: any) { + if (err instanceof NotFoundError) { + // Still generating — keep polling + continue + } + throw err + } + } + } +} From f0cceb7aa2e375d97e93f31a4edbff21c79f5cc6 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Sat, 28 Mar 2026 00:52:51 +0100 Subject: [PATCH 13/17] feat: add rca get command to retrieve root cause analysis Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/rca/get.ts | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/cli/src/commands/rca/get.ts diff --git a/packages/cli/src/commands/rca/get.ts b/packages/cli/src/commands/rca/get.ts new file mode 100644 index 000000000..4ff9f8349 --- /dev/null +++ b/packages/cli/src/commands/rca/get.ts @@ -0,0 +1,97 @@ +import { Args, Flags } from '@oclif/core' +import { AuthCommand } from '../authCommand' +import { outputFlag } from '../../helpers/flags' +import * as api from '../../rest/api' +import { NotFoundError } from '../../rest/errors' +import { formatRcaCompleted } from '../../formatters/rca' + +const POLL_INTERVAL_MS = 2000 + +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 { + // Try to fetch the RCA directly + try { + const { data: rca } = await api.rca.get(args.id) + const fmt = flags.output === 'json' ? 'json' : flags.output === 'md' ? 'md' : 'terminal' + this.log(formatRcaCompleted(rca, fmt)) + return + } catch (err: any) { + if (!(err instanceof NotFoundError)) { + throw err + } + + // 404 — either doesn't exist or 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 this.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 + } + } + + private async pollUntilComplete (rcaId: string) { + while (true) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) + try { + const { data } = await api.rca.get(rcaId) + return data + } catch (err: any) { + if (err instanceof NotFoundError) { + continue + } + throw err + } + } + } +} From d3553ba35eb022b7347d6822433f0e4ec822765f Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 2 Apr 2026 14:36:02 +0200 Subject: [PATCH 14/17] feat: poll on 202 instead of 404 for pending RCA status The public RCA GET endpoint now returns 202 while analysis is in progress and 404 only for genuinely missing resources. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/rca/get.ts | 60 ++++++++++++---------------- packages/cli/src/commands/rca/run.ts | 13 ++---- 2 files changed, 30 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/rca/get.ts b/packages/cli/src/commands/rca/get.ts index 4ff9f8349..0dd4833bd 100644 --- a/packages/cli/src/commands/rca/get.ts +++ b/packages/cli/src/commands/rca/get.ts @@ -2,7 +2,6 @@ import { Args, Flags } from '@oclif/core' import { AuthCommand } from '../authCommand' import { outputFlag } from '../../helpers/flags' import * as api from '../../rest/api' -import { NotFoundError } from '../../rest/errors' import { formatRcaCompleted } from '../../formatters/rca' const POLL_INTERVAL_MS = 2000 @@ -34,37 +33,34 @@ export default class RcaGet extends AuthCommand { this.style.outputFormat = flags.output try { - // Try to fetch the RCA directly - try { - const { data: rca } = await api.rca.get(args.id) + // 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(rca, fmt)) + this.log(formatRcaCompleted(response.data, fmt)) return - } catch (err: any) { - if (!(err instanceof NotFoundError)) { - throw err - } + } - // 404 — either doesn't exist or 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 + // 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 + 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 @@ -83,15 +79,11 @@ export default class RcaGet extends AuthCommand { private async pollUntilComplete (rcaId: string) { while (true) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - try { - const { data } = await api.rca.get(rcaId) - return data - } catch (err: any) { - if (err instanceof NotFoundError) { - continue - } - throw err + const response = await api.rca.get(rcaId) + if (response.status === 202) { + continue } + return response.data } } } diff --git a/packages/cli/src/commands/rca/run.ts b/packages/cli/src/commands/rca/run.ts index 86e1bd610..097465160 100644 --- a/packages/cli/src/commands/rca/run.ts +++ b/packages/cli/src/commands/rca/run.ts @@ -87,16 +87,11 @@ export default class RcaRun extends AuthCommand { private async pollUntilComplete (rcaId: string) { while (true) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - try { - const { data } = await api.rca.get(rcaId) - return data - } catch (err: any) { - if (err instanceof NotFoundError) { - // Still generating — keep polling - continue - } - throw err + const response = await api.rca.get(rcaId) + if (response.status === 202) { + continue } + return response.data } } } From 144f249558d3cf49bef4f7348bc106130ee2e981 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 2 Apr 2026 14:43:54 +0200 Subject: [PATCH 15/17] fix: use path.join in init test for Windows compatibility The assertion hardcoded a Unix-style path, failing on Windows CI where path.join produces backslashes. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/__tests__/init.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From ec7172e07f07a494651f68d1d52fefc71cfba4f2 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 2 Apr 2026 15:01:03 +0200 Subject: [PATCH 16/17] feat: add rca topic description and update help e2e test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/e2e/__tests__/help.spec.ts | 1 + packages/cli/package.json | 3 +++ 2 files changed, 4 insertions(+) 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." } From bc877be7762c1b55b44fa7b3289aec9599ee4629 Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 2 Apr 2026 15:40:46 +0200 Subject: [PATCH 17/17] feat: add RCA hint in checks get and extract pollUntilComplete Show a suggestion to run RCA for error groups that don't have one yet in the checks get terminal output. Move the shared polling logic into the Rca API client class to deduplicate across rca run and rca get. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/rca/get.ts | 15 +-------------- packages/cli/src/commands/rca/run.ts | 15 +-------------- .../__tests__/__snapshots__/checks.spec.ts.snap | 4 +++- packages/cli/src/formatters/checks.ts | 14 +++++++++++++- packages/cli/src/rest/rca.ts | 13 +++++++++++++ 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/commands/rca/get.ts b/packages/cli/src/commands/rca/get.ts index 0dd4833bd..5288ffade 100644 --- a/packages/cli/src/commands/rca/get.ts +++ b/packages/cli/src/commands/rca/get.ts @@ -4,8 +4,6 @@ import { outputFlag } from '../../helpers/flags' import * as api from '../../rest/api' import { formatRcaCompleted } from '../../formatters/rca' -const POLL_INTERVAL_MS = 2000 - export default class RcaGet extends AuthCommand { static hidden = false static readOnly = true @@ -66,7 +64,7 @@ export default class RcaGet extends AuthCommand { // Watch mode: poll until complete this.style.actionStart('Waiting for root cause analysis...') - const rca = await this.pollUntilComplete(args.id) + const rca = await api.rca.pollUntilComplete(args.id) this.style.actionSuccess() this.log(formatRcaCompleted(rca, 'terminal')) @@ -75,15 +73,4 @@ export default class RcaGet extends AuthCommand { process.exitCode = 1 } } - - private async pollUntilComplete (rcaId: string) { - while (true) { - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - const response = await api.rca.get(rcaId) - if (response.status === 202) { - continue - } - return response.data - } - } } diff --git a/packages/cli/src/commands/rca/run.ts b/packages/cli/src/commands/rca/run.ts index 097465160..e5acfaf68 100644 --- a/packages/cli/src/commands/rca/run.ts +++ b/packages/cli/src/commands/rca/run.ts @@ -6,8 +6,6 @@ import * as api from '../../rest/api' import { NotFoundError, InadequateEntitlementsError } from '../../rest/errors' import { formatRcaPending, formatRcaCompleted } from '../../formatters/rca' -const POLL_INTERVAL_MS = 2000 - export default class RcaRun extends AuthCommand { static hidden = false static readOnly = false @@ -61,7 +59,7 @@ export default class RcaRun extends AuthCommand { this.log('') this.style.actionStart('Waiting for root cause analysis...') - const rca = await this.pollUntilComplete(rcaId) + const rca = await api.rca.pollUntilComplete(rcaId) this.style.actionSuccess() this.log(formatRcaCompleted(rca, 'terminal')) @@ -83,15 +81,4 @@ export default class RcaRun extends AuthCommand { process.exitCode = 1 } } - - private async pollUntilComplete (rcaId: string) { - while (true) { - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) - const response = await api.rca.get(rcaId) - if (response.status === 202) { - continue - } - return response.data - } - } } 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 fb74a0a1a..4c6c0d108 100644 --- a/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap +++ b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap @@ -85,7 +85,9 @@ exports[`formatErrorGroups > renders markdown error groups > error-groups-md 1`] exports[`formatErrorGroups > renders terminal error groups > error-groups-terminal 1`] = ` "ERROR GROUPS ERROR FIRST SEEN LAST SEEN RCA ERROR GROUP ID -TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago - eg-1" +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/checks.ts b/packages/cli/src/formatters/checks.ts index c864f739e..a559cd741 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -359,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/rest/rca.ts b/packages/cli/src/rest/rca.ts index 5c8b35e8c..eb2762607 100644 --- a/packages/cli/src/rest/rca.ts +++ b/packages/cli/src/rest/rca.ts @@ -5,6 +5,8 @@ export interface TriggerRcaResponse { id: string } +const POLL_INTERVAL_MS = 2000 + class Rca { api: AxiosInstance constructor (api: AxiosInstance) { @@ -20,6 +22,17 @@ class Rca { 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