Skip to content

Commit 730778a

Browse files
perf(cli): project recent-results fields in checks get (#1353)
The default `checks get <id>` screen only renders a narrow recent-results table, but it was fetching full result bodies (metadata, assets, apiCheckResult, browserCheckResult, ...) from /v2/check-results, which can trigger slow paths for result-heavy checks. Now that the backend supports a `fields` query parameter (checkly/monorepo#2384), request only the columns the table needs. - rest: add CheckResultField union, extend ListCheckResultsParams with `fields?`, and normalize the array to a comma-separated query string in getAll() rather than relying on Axios array serialization. - checks get: pass a fixed internal projection (id, startedAt, runLocation, hasErrors, hasFailures, isDegraded, responseTime) for detail/markdown output. No new public flag is added; the projection is internal and fixed. - JSON output intentionally omits `fields` to preserve full `results` entries for existing consumers. - --result drilldown and the --include-attempts window query are unchanged and still request full payloads. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3397ed3 commit 730778a

5 files changed

Lines changed: 267 additions & 8 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { CheckResult } from '../../rest/check-results.js'
3+
4+
vi.mock('../../rest/api.js', () => ({
5+
checks: { get: vi.fn() },
6+
checkStatuses: { get: vi.fn() },
7+
checkResults: { get: vi.fn(), getAll: vi.fn() },
8+
errorGroups: { getByCheckId: vi.fn(), get: vi.fn() },
9+
analytics: { get: vi.fn() },
10+
}))
11+
12+
import * as api from '../../rest/api.js'
13+
import ChecksGet from '../checks/get.js'
14+
15+
// The fixed, internal projection the recent-results table needs. Exactly the
16+
// fields formatResults() and resolveResultStatus() read — kept in sync with
17+
// RECENT_RESULTS_FIELDS in checks/get.ts.
18+
const EXPECTED_FIELDS = ['id', 'startedAt', 'runLocation', 'hasErrors', 'hasFailures', 'isDegraded', 'responseTime']
19+
20+
function makeCheck () {
21+
return {
22+
id: 'check-1',
23+
name: 'My API Check',
24+
description: null,
25+
checkType: 'API',
26+
activated: true,
27+
muted: false,
28+
frequency: 10,
29+
locations: ['eu-west-1'],
30+
privateLocations: [],
31+
tags: [],
32+
groupId: null,
33+
scriptPath: null,
34+
request: { url: 'https://example.com', method: 'GET' },
35+
created_at: '2026-01-01T00:00:00.000Z',
36+
updated_at: '2026-01-01T00:00:00.000Z',
37+
}
38+
}
39+
40+
function makeResult (overrides: Partial<CheckResult> = {}): CheckResult {
41+
return {
42+
id: 'r1',
43+
checkId: 'check-1',
44+
name: 'My API Check',
45+
hasFailures: false,
46+
hasErrors: false,
47+
isDegraded: false,
48+
overMaxResponseTime: false,
49+
runLocation: 'eu-west-1',
50+
startedAt: '2026-05-20T08:00:00.000Z',
51+
stoppedAt: '2026-05-20T08:00:04.000Z',
52+
created_at: '2026-05-20T08:00:04.000Z',
53+
responseTime: 4000,
54+
checkRunId: 1,
55+
attempts: 1,
56+
resultType: 'FINAL',
57+
sequenceId: 'seq-1',
58+
...overrides,
59+
}
60+
}
61+
62+
function createCommandContext (parsed: unknown) {
63+
const logged: string[] = []
64+
return Object.assign(Object.create(ChecksGet.prototype), {
65+
parse: vi.fn().mockResolvedValue(parsed),
66+
log: vi.fn((msg?: string) => {
67+
if (msg) logged.push(msg)
68+
}),
69+
style: { outputFormat: undefined, longError: vi.fn() },
70+
logged,
71+
})
72+
}
73+
74+
describe('checks get recent-results projection', () => {
75+
beforeEach(() => {
76+
vi.clearAllMocks()
77+
process.exitCode = undefined
78+
vi.mocked(api.checks.get).mockResolvedValue({ data: makeCheck() } as any)
79+
vi.mocked(api.checkStatuses.get).mockResolvedValue({ data: undefined } as any)
80+
vi.mocked(api.errorGroups.getByCheckId).mockResolvedValue({ data: [] } as any)
81+
vi.mocked(api.analytics.get).mockResolvedValue({ data: undefined } as any)
82+
vi.mocked(api.checkResults.getAll).mockResolvedValue({
83+
data: { entries: [makeResult()], nextId: null, length: 1 },
84+
} as any)
85+
})
86+
87+
it('requests only the narrow projection for default (detail) output', async () => {
88+
const ctx = createCommandContext({
89+
args: { id: 'check-1' },
90+
flags: { 'results-limit': 10, 'output': 'detail', 'stats-range': 'last24Hours' },
91+
})
92+
93+
await ChecksGet.prototype.run.call(ctx as any)
94+
95+
expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
96+
limit: 10,
97+
nextId: undefined,
98+
fields: EXPECTED_FIELDS,
99+
})
100+
})
101+
102+
it('forwards a results cursor unchanged alongside the projection', async () => {
103+
const ctx = createCommandContext({
104+
args: { id: 'check-1' },
105+
flags: { 'results-limit': 25, 'results-cursor': 'cursor-abc', 'output': 'detail', 'stats-range': 'last24Hours' },
106+
})
107+
108+
await ChecksGet.prototype.run.call(ctx as any)
109+
110+
expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
111+
limit: 25,
112+
nextId: 'cursor-abc',
113+
fields: EXPECTED_FIELDS,
114+
})
115+
})
116+
117+
it('requests the narrow projection for markdown output', async () => {
118+
const ctx = createCommandContext({
119+
args: { id: 'check-1' },
120+
flags: { 'results-limit': 10, 'output': 'md', 'stats-range': 'last24Hours' },
121+
})
122+
123+
await ChecksGet.prototype.run.call(ctx as any)
124+
125+
expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
126+
limit: 10,
127+
nextId: undefined,
128+
fields: EXPECTED_FIELDS,
129+
})
130+
})
131+
132+
it('does not project fields for json output (preserves full results entries)', async () => {
133+
const ctx = createCommandContext({
134+
args: { id: 'check-1' },
135+
flags: { 'results-limit': 10, 'output': 'json', 'stats-range': 'last24Hours' },
136+
})
137+
138+
await ChecksGet.prototype.run.call(ctx as any)
139+
140+
expect(api.checkResults.getAll).toHaveBeenCalledTimes(1)
141+
const callArgs = vi.mocked(api.checkResults.getAll).mock.calls[0][1]
142+
expect(callArgs).not.toHaveProperty('fields')
143+
expect(callArgs).toMatchObject({ limit: 10, nextId: undefined })
144+
})
145+
146+
it('--result drilldown uses the detail endpoint and never lists with projection', async () => {
147+
vi.mocked(api.checkResults.get).mockResolvedValue({
148+
data: makeResult({ id: 'res-42', resultType: 'FINAL', attempts: 1 }),
149+
} as any)
150+
const ctx = createCommandContext({
151+
args: { id: 'check-1' },
152+
flags: { result: 'res-42', output: 'detail' },
153+
})
154+
155+
await ChecksGet.prototype.run.call(ctx as any)
156+
157+
expect(api.checkResults.get).toHaveBeenCalledWith('check-1', 'res-42')
158+
expect(api.checkResults.getAll).not.toHaveBeenCalled()
159+
})
160+
})

packages/cli/src/commands/checks/get.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,22 @@ import {
2121
formatAttemptsSection,
2222
groupAttemptsBySequence,
2323
} from '../../formatters/check-result-detail.js'
24-
import type { CheckResult } from '../../rest/check-results.js'
24+
import type { CheckResult, CheckResultField, ListCheckResultsParams } from '../../rest/check-results.js'
2525
import { formatRcaDetail, formatRcaHint, transformErrorGroupForJson } from '../../formatters/rca.js'
2626
import { quickRangeValues, type QuickRange, type GroupBy } from '../../rest/analytics.js'
2727
import { formatAnalyticsSection } from '../../formatters/analytics.js'
2828

29+
// Internal, fixed projection for the embedded recent-results table. These are
30+
// exactly the fields formatResults() and resolveResultStatus() read; requesting
31+
// the wide result bodies (apiCheckResult, browserCheckResult, metadata, assets,
32+
// …) would make the backend select and decorate payloads this view never
33+
// renders. This is intentionally not a user-facing flag: `checks get` aggregates
34+
// check details, status, analytics, error groups, and results, so a generic
35+
// `--fields` would be ambiguous.
36+
const RECENT_RESULTS_FIELDS: CheckResultField[] = [
37+
'id', 'startedAt', 'runLocation', 'hasErrors', 'hasFailures', 'isDegraded', 'responseTime',
38+
]
39+
2940
export default class ChecksGet extends AuthCommand {
3041
static hidden = false
3142
static readOnly = true
@@ -96,13 +107,23 @@ export default class ChecksGet extends AuthCommand {
96107
// Fetch check first (need checkType for analytics)
97108
const { data: check } = await api.checks.get(args.id)
98109

110+
// The recent-results table only needs a narrow column set, so project to
111+
// those fields and let the backend skip wide result payloads. JSON output
112+
// is the exception: it exposes full `results` entries, so we omit `fields`
113+
// there to preserve backwards compatibility for existing consumers.
114+
const resultsParams: ListCheckResultsParams = {
115+
limit: flags['results-limit'],
116+
nextId: flags['results-cursor'],
117+
}
118+
if (flags.output !== 'json') {
119+
resultsParams.fields = RECENT_RESULTS_FIELDS
120+
}
121+
99122
// Fetch remaining data in parallel
100123
const [statusResp, resultsResp, errorGroupsResp, analyticsResp] = await Promise.all([
101124
api.checkStatuses.get(args.id).catch(() => ({ data: undefined })),
102-
api.checkResults.getAll(args.id, {
103-
limit: flags['results-limit'],
104-
nextId: flags['results-cursor'],
105-
}).catch(() => ({ data: { entries: [], nextId: null, length: 0 } })),
125+
api.checkResults.getAll(args.id, resultsParams)
126+
.catch(() => ({ data: { entries: [], nextId: null, length: 0 } })),
106127
api.errorGroups.getByCheckId(args.id).catch(() => ({ data: [] })),
107128
api.analytics.get(args.id, check.checkType, {
108129
quickRange: (flags['stats-range'] ?? 'last24Hours') as QuickRange,

packages/cli/src/constructs/__tests__/playwright-check.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ describe('PlaywrightCheck', () => {
358358
'teardown.ts',
359359
'tsconfig.playwright.json',
360360
])
361-
})
361+
}, DEFAULT_TEST_TIMEOUT)
362362
})
363363

364364
describe('test-global-files-bundling-without-projects', () => {
@@ -411,7 +411,7 @@ describe('PlaywrightCheck', () => {
411411
'teardown.ts',
412412
'tsconfig.playwright.json',
413413
])
414-
})
414+
}, DEFAULT_TEST_TIMEOUT)
415415
})
416416

417417
describe('headless', () => {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import type { AxiosInstance } from 'axios'
3+
import CheckResults from '../check-results.js'
4+
5+
function makeAxiosMock (): AxiosInstance {
6+
return {
7+
get: vi.fn().mockResolvedValue({ data: { entries: [], nextId: null, length: 0 } }),
8+
} as unknown as AxiosInstance
9+
}
10+
11+
describe('CheckResults.getAll()', () => {
12+
let api: AxiosInstance
13+
let checkResults: CheckResults
14+
15+
beforeEach(() => {
16+
api = makeAxiosMock()
17+
checkResults = new CheckResults(api)
18+
})
19+
20+
it('serializes the fields array as a comma-separated query string', async () => {
21+
await checkResults.getAll('check-1', { limit: 10, fields: ['id', 'startedAt', 'responseTime'] })
22+
23+
expect(api.get).toHaveBeenCalledWith('/v2/check-results/check-1', {
24+
params: { limit: 10, fields: 'id,startedAt,responseTime' },
25+
})
26+
})
27+
28+
it('does not send a fields param when fields is omitted', async () => {
29+
await checkResults.getAll('check-1', { limit: 5 })
30+
31+
const [, config] = vi.mocked(api.get).mock.calls[0]
32+
expect((config as any).params.fields).toBeUndefined()
33+
})
34+
35+
it('passes other params through untouched', async () => {
36+
await checkResults.getAll('check-1', { from: 100, to: 200, resultType: 'ALL', nextId: 'cursor' })
37+
38+
const [, config] = vi.mocked(api.get).mock.calls[0]
39+
expect((config as any).params).toMatchObject({ from: 100, to: 200, resultType: 'ALL', nextId: 'cursor' })
40+
})
41+
})

packages/cli/src/rest/check-results.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,45 @@ export interface CheckResultsPage {
174174
nextId: string | null
175175
}
176176

177+
// Result fields the backend `GET /v2/check-results/{checkId}` endpoint accepts
178+
// for projection via the `fields` query parameter. Requesting a narrow subset
179+
// lets the backend skip selecting and decorating wide payloads (metadata,
180+
// assets, apiCheckResult, browserCheckResult, …) that a given view never needs.
181+
export type CheckResultField =
182+
| 'id'
183+
| 'name'
184+
| 'checkId'
185+
| 'hasFailures'
186+
| 'hasErrors'
187+
| 'isDegraded'
188+
| 'isCancelled'
189+
| 'overMaxResponseTime'
190+
| 'runLocation'
191+
| 'startedAt'
192+
| 'stoppedAt'
193+
| 'created_at'
194+
| 'createdAt'
195+
| 'responseTime'
196+
| 'apiCheckResult'
197+
| 'browserCheckResult'
198+
| 'multiStepCheckResult'
199+
| 'agenticCheckResult'
200+
| 'playwrightCheckResult'
201+
| 'checkRunId'
202+
| 'attempts'
203+
| 'resultType'
204+
| 'sequenceId'
205+
| 'traceId'
206+
| 'errorGroupIds'
207+
177208
export interface ListCheckResultsParams {
178209
limit?: number
179210
nextId?: string
180211
from?: number
181212
to?: number
182213
hasFailures?: boolean
183214
resultType?: 'FINAL' | 'ATTEMPT' | 'ALL'
215+
fields?: CheckResultField[]
184216
}
185217

186218
class CheckResults {
@@ -190,7 +222,12 @@ class CheckResults {
190222
}
191223

192224
getAll (checkId: string, params: ListCheckResultsParams = {}) {
193-
return this.api.get<CheckResultsPage>(`/v2/check-results/${checkId}`, { params })
225+
// Serialize `fields` as a single comma-separated value rather than relying on
226+
// Axios array serialization. The backend accepts both `fields=id,startedAt`
227+
// and repeated `fields=id&fields=startedAt`, and the comma form is the
228+
// safest, most predictable shape across HTTP clients.
229+
const requestParams = { ...params, fields: params.fields?.join(',') }
230+
return this.api.get<CheckResultsPage>(`/v2/check-results/${checkId}`, { params: requestParams })
194231
}
195232

196233
get (checkId: string, checkResultId: string) {

0 commit comments

Comments
 (0)