Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a9c7e90
feat: add RootCauseAnalysis types and test fixtures
thebiglabasky Mar 27, 2026
540f084
feat: add RCA column to error groups table
thebiglabasky Mar 27, 2026
1aeb80c
feat: add RCA rendering module with terminal, markdown, and JSON support
thebiglabasky Mar 27, 2026
504b3f7
feat: wire RCA into error group detail and JSON output
thebiglabasky Mar 27, 2026
8bcf280
Merge branch 'main' into herve/add-rca-check-results
thebiglabasky Apr 2, 2026
4d09914
feat: add RootCauseAnalysis types and test fixtures
thebiglabasky Mar 27, 2026
6957960
feat: add RCA column to error groups table
thebiglabasky Mar 27, 2026
9040b8a
feat: add RCA rendering module with terminal, markdown, and JSON support
thebiglabasky Mar 27, 2026
c568a16
feat: wire RCA into error group detail and JSON output
thebiglabasky Mar 27, 2026
e42c99b
feat: add Error Group ID column to error groups table
thebiglabasky Mar 27, 2026
a6614f3
feat: add RCA API client for trigger and get endpoints
thebiglabasky Mar 27, 2026
920324f
feat: add pending and completed RCA formatters
thebiglabasky Mar 27, 2026
cd2b839
feat: add rca run command to trigger root cause analysis
thebiglabasky Mar 27, 2026
f0cceb7
feat: add rca get command to retrieve root cause analysis
thebiglabasky Mar 27, 2026
d3553ba
feat: poll on 202 instead of 404 for pending RCA status
thebiglabasky Apr 2, 2026
ffaeb34
Merge branch 'herve/add-rca-trigger' into herve/add-rca-check-results
thebiglabasky Apr 2, 2026
144f249
fix: use path.join in init test for Windows compatibility
thebiglabasky Apr 2, 2026
ec7172e
feat: add rca topic description and update help e2e test
thebiglabasky Apr 2, 2026
bc877be
feat: add RCA hint in checks get and extract pollUntilComplete
thebiglabasky Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/e2e/__tests__/help.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/__tests__/init.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { join } from 'path'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Init from '../init'

Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/commands/checks/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

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

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

Expand Down Expand Up @@ -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
}
Expand All @@ -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`)
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/commands/rca/get.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
}
84 changes: 84 additions & 0 deletions packages/cli/src/commands/rca/run.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
}
74 changes: 73 additions & 1 deletion packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 ---
Expand Down Expand Up @@ -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: [],
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`] = `
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/formatters/__tests__/checks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
browserCheckResult,
activeErrorGroup,
archivedErrorGroup,
errorGroupWithRca,
errorGroupWithoutRca,
} from './__fixtures__/fixtures'

// Pin time for timeAgo used in results/error groups
Expand Down Expand Up @@ -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('| - |')
})
})
Loading
Loading