Skip to content

Commit 713dbba

Browse files
feat(cli): add checks stats command (#1256)
* feat(cli): add `checks stats` command for batch check analytics Adds a new `checks stats` subcommand that shows availability, response times, latency, and packet loss across all checks using the batch analytics endpoint. Supports adaptive columns based on check types present, filtering by tag/type/search, pagination, and json/md/table output formats. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): add right-alignment for numeric columns in table output Adds optional `align: 'right'` to ColumnDef for decimal-aligned numbers. Applied to all metric columns in checks stats (availability, response time, latency, packet loss) for easier visual comparison. Markdown tables get `---:` separator for right-aligned columns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): respect --output flag for empty checks stats result Return valid JSON with empty data array when no checks match and output is json, instead of plain text. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cli): add e2e tests for checks stats command Adds populated account tests (default output, json, markdown, limit, range, empty search filter) and empty account tests (plain text and json empty state). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(cli): explain why BatchQuickRange excludes last30Days and thisMonth Rolling windows defeat ClickHouse caching under concurrency. lastMonth is kept because its frozen data allows long cache TTLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): add trailing gap after right-aligned table columns Right-aligned columns had no spacing between them because all padding went to the left. Reserve a 2-char trailing gap within the column width so columns don't stick together. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): use consistent ms formatting in stats table for decimal alignment Replace formatMetricValue with a table-specific formatTableMetric that always keeps ms values in milliseconds with comma separators (340ms, 1,240ms, 25,070ms) instead of switching to seconds at 1000ms. This ensures all values in a column share the same suffix, so right-alignment produces true decimal alignment for easy visual comparison. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): improve stats table formatting — seconds with 3dp and last-column alignment Display timing metrics as seconds with 3 decimal places (e.g. 0.034s, 14.710s) for readable comparison across all magnitudes. Fix right-alignment on the last table column by adding trailingGap parameter to padColumn. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3a80e09 commit 713dbba

10 files changed

Lines changed: 593 additions & 6 deletions

File tree

packages/cli/e2e/__tests__/checks-empty-account.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ describe.skipIf(!apiKey || !accountId)('checks commands on empty account', () =>
4949
expect(result.stdout).toContain('No checks matching')
5050
})
5151

52+
it('should show "No checks found." for checks stats', async () => {
53+
const result = await runChecklyCli({
54+
args: ['checks', 'stats'],
55+
apiKey,
56+
accountId,
57+
})
58+
expect(result.status).toBe(0)
59+
expect(result.stdout).toContain('No checks found.')
60+
})
61+
62+
it('should return empty JSON for checks stats --output json', async () => {
63+
const result = await runChecklyCli({
64+
args: ['checks', 'stats', '--output', 'json'],
65+
apiKey,
66+
accountId,
67+
})
68+
expect(result.status).toBe(0)
69+
const parsed = JSON.parse(result.stdout)
70+
expect(parsed.data).toEqual([])
71+
expect(parsed.pagination.total).toBe(0)
72+
expect(parsed.range).toBe('last24Hours')
73+
})
74+
5275
it('should fail gracefully for checks get on empty account', async () => {
5376
const result = await runChecklyCli({
5477
args: ['checks', 'get', '00000000-0000-0000-0000-000000000000'],
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import config from 'config'
2+
import { describe, it, expect } from 'vitest'
3+
4+
import { runChecklyCli } from '../run-checkly'
5+
6+
describe('checkly checks stats', () => {
7+
it('should show stats with default output', async () => {
8+
const result = await runChecklyCli({
9+
args: ['checks', 'stats'],
10+
apiKey: config.get('apiKey'),
11+
accountId: config.get('accountId'),
12+
})
13+
expect(result.status).toBe(0)
14+
expect(result.stdout).toBeTruthy()
15+
expect(result.stdout).toContain('STATS')
16+
})
17+
18+
it('should output valid JSON with --output json', async () => {
19+
const result = await runChecklyCli({
20+
args: ['checks', 'stats', '--output', 'json'],
21+
apiKey: config.get('apiKey'),
22+
accountId: config.get('accountId'),
23+
})
24+
expect(result.status).toBe(0)
25+
const parsed = JSON.parse(result.stdout)
26+
expect(parsed).toHaveProperty('data')
27+
expect(Array.isArray(parsed.data)).toBe(true)
28+
expect(parsed).toHaveProperty('range')
29+
expect(parsed).toHaveProperty('pagination')
30+
expect(parsed.data.length).toBeGreaterThan(0)
31+
expect(parsed.data[0]).toHaveProperty('checkId')
32+
expect(parsed.data[0]).toHaveProperty('availability')
33+
})
34+
35+
it('should output markdown with --output md', async () => {
36+
const result = await runChecklyCli({
37+
args: ['checks', 'stats', '--output', 'md'],
38+
apiKey: config.get('apiKey'),
39+
accountId: config.get('accountId'),
40+
})
41+
expect(result.status).toBe(0)
42+
expect(result.stdout).toContain('| Name | Type | Status |')
43+
})
44+
45+
it('should respect --limit flag', async () => {
46+
const result = await runChecklyCli({
47+
args: ['checks', 'stats', '--output', 'json', '--limit', '1'],
48+
apiKey: config.get('apiKey'),
49+
accountId: config.get('accountId'),
50+
})
51+
expect(result.status).toBe(0)
52+
const parsed = JSON.parse(result.stdout)
53+
expect(parsed.data).toHaveLength(1)
54+
expect(parsed.pagination.limit).toBe(1)
55+
})
56+
57+
it('should respect --range flag', async () => {
58+
const result = await runChecklyCli({
59+
args: ['checks', 'stats', '--output', 'json', '--range', 'last7Days'],
60+
apiKey: config.get('apiKey'),
61+
accountId: config.get('accountId'),
62+
})
63+
expect(result.status).toBe(0)
64+
const parsed = JSON.parse(result.stdout)
65+
expect(parsed.range).toBe('last7Days')
66+
})
67+
68+
it('should return no results for impossible search filter', async () => {
69+
const result = await runChecklyCli({
70+
args: ['checks', 'stats', '--search', 'zzz-nonexistent-check-name-zzz'],
71+
apiKey: config.get('apiKey'),
72+
accountId: config.get('accountId'),
73+
})
74+
expect(result.status).toBe(0)
75+
expect(result.stdout).toContain('No checks found.')
76+
})
77+
78+
it('should return empty JSON for impossible search filter with --output json', async () => {
79+
const result = await runChecklyCli({
80+
args: ['checks', 'stats', '--search', 'zzz-nonexistent-check-name-zzz', '--output', 'json'],
81+
apiKey: config.get('apiKey'),
82+
accountId: config.get('accountId'),
83+
})
84+
expect(result.status).toBe(0)
85+
const parsed = JSON.parse(result.stdout)
86+
expect(parsed.data).toEqual([])
87+
expect(parsed.pagination.total).toBe(0)
88+
})
89+
})

packages/cli/src/commands/__tests__/command-metadata.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BaseCommand } from '../baseCommand'
44
// Import all non-hidden command classes
55
import ChecksList from '../checks/list'
66
import ChecksGet from '../checks/get'
7+
import ChecksStats from '../checks/stats'
78
import StatusPagesList from '../status-pages/list'
89
import StatusPagesGet from '../status-pages/get'
910
import IncidentsList from '../incidents/list'
@@ -36,6 +37,7 @@ import SyncPlaywright from '../sync-playwright'
3637
const commands: Array<[string, typeof BaseCommand]> = [
3738
['checks list', ChecksList],
3839
['checks get', ChecksGet],
40+
['checks stats', ChecksStats],
3941
['status-pages list', StatusPagesList],
4042
['status-pages get', StatusPagesGet],
4143
['incidents list', IncidentsList],
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { Args, Flags } from '@oclif/core'
2+
import { AuthCommand } from '../authCommand'
3+
import { outputFlag } from '../../helpers/flags'
4+
import * as api from '../../rest/api'
5+
import { batchQuickRangeValues, type BatchQuickRange } from '../../rest/batch-analytics'
6+
import type { CheckWithStatus, PaginationInfo } from '../../formatters/checks'
7+
import { formatSummaryBar, formatPaginationInfo } from '../../formatters/checks'
8+
import type { OutputFormat } from '../../formatters/render'
9+
import type { StatsRow } from '../../formatters/batch-stats'
10+
import { formatBatchStats, formatBatchStatsNavigationHints } from '../../formatters/batch-stats'
11+
import { allCheckTypes } from '../../constants'
12+
13+
const MAX_BATCH_SIZE = 100
14+
15+
export default class ChecksStats extends AuthCommand {
16+
static hidden = false
17+
static readOnly = true
18+
static idempotent = true
19+
static description = 'Show analytics stats for your checks.'
20+
21+
static args = {
22+
checkIds: Args.string({
23+
description: 'One or more check IDs to get stats for.',
24+
required: false,
25+
}),
26+
}
27+
28+
static strict = false
29+
30+
static flags = {
31+
range: Flags.string({
32+
char: 'r',
33+
description: 'Time range for stats.',
34+
options: batchQuickRangeValues,
35+
default: 'last24Hours',
36+
}),
37+
limit: Flags.integer({
38+
char: 'l',
39+
description: 'Number of checks to return (1-100).',
40+
default: 25,
41+
}),
42+
page: Flags.integer({
43+
char: 'p',
44+
description: 'Page number.',
45+
default: 1,
46+
}),
47+
tag: Flags.string({
48+
char: 't',
49+
description: 'Filter by tag. Can be specified multiple times.',
50+
multiple: true,
51+
}),
52+
search: Flags.string({
53+
char: 's',
54+
description: 'Filter checks by name (case-insensitive).',
55+
}),
56+
type: Flags.string({
57+
description: 'Filter by check type.',
58+
options: allCheckTypes,
59+
}),
60+
output: outputFlag({ default: 'table' }),
61+
}
62+
63+
async run (): Promise<void> {
64+
const { flags, argv } = await this.parse(ChecksStats)
65+
this.style.outputFormat = flags.output
66+
const range = flags.range as BatchQuickRange
67+
const { page, limit } = flags
68+
69+
try {
70+
// Collect explicit check IDs from positional args
71+
const explicitIds = (argv as string[]).filter(a => !a.startsWith('-'))
72+
73+
let checksWithStatus: CheckWithStatus[]
74+
let totalChecks: number
75+
76+
if (explicitIds.length > 0) {
77+
// Fetch all checks (paginate through all pages), filter to requested IDs
78+
const [allChecks, statuses] = await Promise.all([
79+
api.checks.fetchAll(),
80+
api.checkStatuses.fetchAll().catch(() => []),
81+
])
82+
const statusMap = new Map(statuses.map(s => [s.checkId, s]))
83+
const idSet = new Set(explicitIds)
84+
checksWithStatus = allChecks
85+
.filter(c => idSet.has(c.id))
86+
.map(c => ({ ...c, status: statusMap.get(c.id) }))
87+
totalChecks = checksWithStatus.length
88+
} else {
89+
// Paginated fetch with filters
90+
const [paginated, statuses] = await Promise.all([
91+
api.checks.getAllPaginated({
92+
limit,
93+
page,
94+
tag: flags.tag,
95+
checkType: flags.type,
96+
search: flags.search,
97+
}),
98+
api.checkStatuses.fetchAll().catch(() => []),
99+
])
100+
const statusMap = new Map(statuses.map(s => [s.checkId, s]))
101+
checksWithStatus = paginated.checks.map(c => ({ ...c, status: statusMap.get(c.id) }))
102+
totalChecks = paginated.total
103+
}
104+
105+
if (checksWithStatus.length === 0) {
106+
if (flags.output === 'json') {
107+
const totalPages = 0
108+
this.log(JSON.stringify({
109+
data: [],
110+
pagination: { page, limit, total: 0, totalPages },
111+
range,
112+
}, null, 2))
113+
} else {
114+
this.log('No checks found.')
115+
}
116+
return
117+
}
118+
119+
// Fetch batch analytics, chunking if > 100
120+
const checkIds = checksWithStatus.map(c => c.id)
121+
const analyticsResults = await this.fetchBatchAnalytics(checkIds, range)
122+
const analyticsMap = new Map(analyticsResults.map(a => [a.checkId, a]))
123+
124+
// Merge into stats rows
125+
const rows: StatsRow[] = checksWithStatus.map(c => ({
126+
...c,
127+
analytics: analyticsMap.get(c.id),
128+
}))
129+
130+
const pagination: PaginationInfo = { page, limit, total: totalChecks }
131+
132+
// JSON output
133+
if (flags.output === 'json') {
134+
const totalPages = Math.ceil(totalChecks / limit)
135+
this.log(JSON.stringify({
136+
data: rows.map(r => ({
137+
checkId: r.id,
138+
name: r.name,
139+
checkType: r.checkType,
140+
activated: r.activated,
141+
status: r.status ? (r.status.hasFailures || r.status.hasErrors ? 'failing' : r.status.isDegraded ? 'degraded' : 'passing') : null,
142+
availability: r.analytics?.availability ?? null,
143+
responseTime_avg: r.analytics?.responseTime_avg ?? null,
144+
responseTime_p95: r.analytics?.responseTime_p95 ?? null,
145+
latency_avg: r.analytics?.latency_avg ?? null,
146+
packetLoss_avg: r.analytics?.packetLoss_avg ?? null,
147+
})),
148+
pagination: { page, limit, total: totalChecks, totalPages },
149+
range,
150+
}, null, 2))
151+
return
152+
}
153+
154+
const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal'
155+
156+
// Markdown output
157+
if (fmt === 'md') {
158+
this.log(formatBatchStats(rows, range, fmt))
159+
return
160+
}
161+
162+
// Terminal output
163+
const output: string[] = []
164+
const statuses = checksWithStatus
165+
.map(c => c.status)
166+
.filter((s): s is NonNullable<typeof s> => s != null)
167+
const activeCheckIds = new Set(checksWithStatus.map(c => c.id))
168+
output.push(formatSummaryBar(statuses, totalChecks, activeCheckIds))
169+
output.push('')
170+
output.push(formatBatchStats(rows, range, fmt))
171+
output.push('')
172+
output.push(formatPaginationInfo(pagination))
173+
output.push('')
174+
175+
// Build active filters for display
176+
const activeFilters: string[] = []
177+
if (flags.tag) activeFilters.push(...flags.tag.map(t => `tag=${t}`))
178+
if (flags.search) activeFilters.push(`search="${flags.search}"`)
179+
if (flags.type) activeFilters.push(`type=${flags.type}`)
180+
181+
output.push(formatBatchStatsNavigationHints(pagination, range, activeFilters))
182+
183+
this.log(output.join('\n'))
184+
} catch (err: any) {
185+
this.style.longError('Failed to get check stats.', err)
186+
process.exitCode = 1
187+
}
188+
}
189+
190+
private async fetchBatchAnalytics (checkIds: string[], range: BatchQuickRange) {
191+
const chunks: string[][] = []
192+
for (let i = 0; i < checkIds.length; i += MAX_BATCH_SIZE) {
193+
chunks.push(checkIds.slice(i, i + MAX_BATCH_SIZE))
194+
}
195+
196+
const results = await Promise.all(
197+
chunks.map(chunk => api.batchAnalytics.get(chunk, range).then(r => r.data)),
198+
)
199+
200+
return results.flat()
201+
}
202+
}

packages/cli/src/formatters/analytics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { formatMs } from './render'
33
import type { OutputFormat } from './render'
44
import type { AnalyticsResponse, AnalyticsSeriesEntry, QuickRange } from '../rest/analytics'
55

6-
const rangeLabels: Record<QuickRange, string> = {
6+
export const rangeLabels: Record<QuickRange, string> = {
77
last24Hours: 'last 24 hours',
88
last7Days: 'last 7 days',
99
last30Days: 'last 30 days',

0 commit comments

Comments
 (0)