From d95aee67aceb104c8bc5fd3885b2fd0a351b0e03 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 25 Jun 2026 18:27:40 +0000 Subject: [PATCH 01/10] feat(reporters): per-type TRACEROUTE/GRPC/SSL result rendering (P4-CLI-RESULTS) Render failure-debug diagnostics for the three uptime monitor types across every CLI result surface, mirroring the 4.1 public check-results fields: - rest/check-results.ts: typed TracerouteCheckResult/GrpcCheckResult/ SslCheckResult interfaces + additive fields on CheckResult/CheckResultField. - formatters/check-result-detail.ts: per-type terminal + markdown diagnostic block (checks results get) keyed on the typed fields. - reporters/util.ts: GRPC/SSL/TRACEROUTE branches in formatCheckResult (checkly test terminal), sourced from the runner artifact (checkRunData). - formatters/batch-stats.ts: add the three types to TIMING_TYPES. - reporters/json.ts: emit a per-type diagnostics object in the JSON report for a failed run (checkly test --reporter json). Snapshot + assertion tests for each type across all three surfaces. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/__fixtures__/fixtures.ts | 114 ++++++ .../check-result-detail.spec.ts.snap | 138 ++++++++ .../__tests__/check-result-detail.spec.ts | 103 ++++++ packages/cli/src/formatters/batch-stats.ts | 2 +- .../cli/src/formatters/check-result-detail.ts | 333 ++++++++++++++++++ .../__tests__/__snapshots__/util.spec.ts.snap | 35 ++ .../fixtures/uptime-check-results.ts | 122 +++++++ .../reporters/__tests__/json-builder.spec.ts | 85 ++++- .../cli/src/reporters/__tests__/util.spec.ts | 49 +++ packages/cli/src/reporters/json.ts | 61 ++++ packages/cli/src/reporters/util.ts | 141 ++++++++ packages/cli/src/rest/check-results.ts | 91 +++++ 12 files changed, 1272 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts diff --git a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts index 47a7ca7ad..420a24e74 100644 --- a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts +++ b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts @@ -6,6 +6,9 @@ import type { BrowserCheckResult, MultiStepCheckResult, AgenticCheckResult, + TracerouteCheckResult, + GrpcCheckResult, + SslCheckResult, } from '../../rest/check-results' import type { ErrorGroup, RootCauseAnalysis } from '../../rest/error-groups' import type { CheckWithStatus } from '../checks' @@ -553,3 +556,114 @@ export const errorGroupWithoutRca: ErrorGroup = { id: 'eg-no-rca', rootCauseAnalyses: [], } + +// --- Traceroute / gRPC / SSL check results (public-API CheckResult shape) --- +// Failing runs of each type, mirroring the 4.1 public check-results fields. + +const baseDiagnosticResult = { + hasErrors: false, + isDegraded: false, + overMaxResponseTime: false, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:01.000Z', + created_at: '2025-06-15T12:00:01.000Z', + attempts: 1, + resultType: 'FINAL' as const, +} + +export const tracerouteCheckResultDetail: TracerouteCheckResult = { + totalHops: 30, + destinationReached: false, + finalHopLatency: { avg_ms: 24.1, best_ms: 22.0, worst_ms: 31.4 }, + requestError: null, + response: { + hostname: 'unreachable.example.com', + resolvedIp: '203.0.113.10', + totalHops: 30, + destinationReached: false, + truncationReason: 'max-hops', + protocol: 'TCP', + finalHopLatency: { avg_ms: 24.1, best_ms: 22.0, worst_ms: 31.4 }, + hops: [ + { hop_number: 1, main_ip: '10.0.0.1', main_host: 'gateway.local', loss_percentage: 0, rtt: { avg: 1.2, best: 1.0, worst: 1.5 } }, + { hop_number: 2, main_ip: '198.51.100.5', loss_percentage: 100, rtt: null, asn: 64500 }, + ], + }, +} + +export const tracerouteCheckResult: CheckResult = { + ...baseDiagnosticResult, + id: 'result-tr-1', + checkId: 'check-tr', + name: 'Traceroute to unreachable host', + hasFailures: true, + responseTime: 1000, + checkRunId: 2001, + tracerouteCheckResult: tracerouteCheckResultDetail, +} + +export const grpcCheckResultDetail: GrpcCheckResult = { + grpcStatusCode: 14, + healthStatus: 2, + requestError: null, + response: { + grpcMode: 'HEALTH', + host: 'grpc.example.com', + resolvedIp: '203.0.113.20', + port: 443, + grpcMethod: 'grpc.health.v1.Health/Check', + responseMessage: '', + grpcStatusCode: 14, + grpcStatusMessage: 'connection refused', + healthStatus: 2, + healthStatusLabel: 'NOT_SERVING', + metadata: [{ key: 'content-type', value: 'application/grpc' }], + discoveredMethods: ['grpc.health.v1.Health/Check', 'grpc.health.v1.Health/Watch'], + }, +} + +export const grpcCheckResult: CheckResult = { + ...baseDiagnosticResult, + id: 'result-grpc-1', + checkId: 'check-grpc', + name: 'gRPC health probe', + hasFailures: true, + responseTime: 90, + checkRunId: 2002, + grpcCheckResult: grpcCheckResultDetail, +} + +export const sslCheckResultDetail: SslCheckResult = { + tlsVersion: 'TLS 1.3', + cipherSuite: 'TLS_AES_256_GCM_SHA384', + daysUntilExpiry: -5, + handshakeTimeMs: 48.2, + chainTrusted: false, + hostnameVerified: false, + baselineVerdict: 'FAIL', + baselineGrade: 'C', + failureCategory: 'expired', + requestError: null, + response: { + resolvedIp: '203.0.113.30', + protocol: 'TLS 1.3', + cipherSuite: 'TLS_AES_256_GCM_SHA384', + handshakeTimeMs: 48.2, + hostnameVerified: false, + chainTrusted: false, + daysUntilExpiry: -5, + ocspStapled: false, + }, +} + +export const sslCheckResult: CheckResult = { + ...baseDiagnosticResult, + id: 'result-ssl-1', + checkId: 'check-ssl', + name: 'SSL certificate expiry', + hasFailures: true, + responseTime: 48, + checkRunId: 2003, + sslCheckResult: sslCheckResultDetail, +} diff --git a/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap index 02c30513c..013f19e03 100644 --- a/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap +++ b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap @@ -281,3 +281,141 @@ ASSETS 1 screenshot(s), 1 trace(s) Use --output json to get asset URLs" `; + +exports[`formatResultDetail > SSL check result > renders markdown snapshot > ssl-result-detail-md 1`] = ` +"# SSL certificate expiry + +| Field | Value | +| --- | --- | +| Status | failing | +| Location | eu-west-1 | +| Response time | 48ms | +| Started | 2025-06-15T12:00:00.000Z | +| Stopped | 2025-06-15T12:00:01.000Z | +| Attempts | 1 | +| Result type | FINAL | +| ID | result-ssl-1 | + +## SSL Result +- **TLS:** TLS 1.3 / TLS_AES_256_GCM_SHA384 +- **Expires in:** expired 5 day(s) ago +- **Handshake:** 48ms +- **Chain trusted:** no +- **Hostname verified:** no +- **Baseline:** FAIL grade C +- **Failure:** expired" +`; + +exports[`formatResultDetail > SSL check result > renders terminal snapshot > ssl-result-detail-terminal 1`] = ` +"SSL certificate expiry + +Status: failing +Location: eu-west-1 +Response time: 48ms +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:01 UTC +Attempts: 1 +Result type: FINAL +ID: result-ssl-1 + +SSL RESULT +Resolved IP: 203.0.113.30 +TLS: TLS 1.3 / TLS_AES_256_GCM_SHA384 +Expires in: expired 5 day(s) ago +Handshake: 48ms +Chain trusted: no +Hostname: no +Baseline: FAIL grade C +Failure: expired" +`; + +exports[`formatResultDetail > Traceroute check result > renders markdown snapshot > traceroute-result-detail-md 1`] = ` +"# Traceroute to unreachable host + +| Field | Value | +| --- | --- | +| Status | failing | +| Location | eu-west-1 | +| Response time | 1.00s | +| Started | 2025-06-15T12:00:00.000Z | +| Stopped | 2025-06-15T12:00:01.000Z | +| Attempts | 1 | +| Result type | FINAL | +| ID | result-tr-1 | + +## Traceroute Result +- **Destination:** unreachable.example.com (203.0.113.10) +- **Hops:** 30 +- **Reached:** no +- **Truncated:** max-hops +- **Final hop:** avg 24ms / best 22ms / worst 31ms" +`; + +exports[`formatResultDetail > Traceroute check result > renders terminal snapshot > traceroute-result-detail-terminal 1`] = ` +"Traceroute to unreachable host + +Status: failing +Location: eu-west-1 +Response time: 1.00s +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:01 UTC +Attempts: 1 +Result type: FINAL +ID: result-tr-1 + +TRACEROUTE RESULT +Destination: unreachable.example.com (203.0.113.10) +Protocol: TCP +Hops: 30 +Reached: no +Truncated: max-hops +Final hop: avg 24ms / best 22ms / worst 31ms + +HOPS + 1 10.0.0.1 (gateway.local) loss 0% rtt avg 1ms / best 1ms / worst 2ms + 2 198.51.100.5 loss 100% AS64500" +`; + +exports[`formatResultDetail > gRPC check result > renders markdown snapshot > grpc-result-detail-md 1`] = ` +"# gRPC health probe + +| Field | Value | +| --- | --- | +| Status | failing | +| Location | eu-west-1 | +| Response time | 90ms | +| Started | 2025-06-15T12:00:00.000Z | +| Stopped | 2025-06-15T12:00:01.000Z | +| Attempts | 1 | +| Result type | FINAL | +| ID | result-grpc-1 | + +## gRPC Result +- **Status:** 14 connection refused +- **Health:** NOT_SERVING +- **Method:** grpc.health.v1.Health/Check +- **Discovered methods:** grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch" +`; + +exports[`formatResultDetail > gRPC check result > renders terminal snapshot > grpc-result-detail-terminal 1`] = ` +"gRPC health probe + +Status: failing +Location: eu-west-1 +Response time: 90ms +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:01 UTC +Attempts: 1 +Result type: FINAL +ID: result-grpc-1 + +GRPC RESULT +Target: grpc.example.com:443 +Method: grpc.health.v1.Health/Check +Mode: HEALTH +Status: 14 connection refused +Health: NOT_SERVING +Methods: grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch +Metadata: + content-type: application/grpc" +`; diff --git a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts index 6ba9f4196..006afc027 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -17,6 +17,9 @@ import { agenticCheckResult, agenticCheckResultWithFailures, agenticCheckResultMinimal, + tracerouteCheckResult, + grpcCheckResult, + sslCheckResult, } from './__fixtures__/fixtures.js' // Pin time for formatDate used in result detail @@ -345,4 +348,104 @@ describe('formatResultDetail', () => { expect(result).not.toContain('## Browser') }) }) + + describe('Traceroute check result', () => { + it('renders terminal snapshot', () => { + const result = stripAnsi(formatResultDetail(tracerouteCheckResult, 'terminal')) + expect(result).toMatchSnapshot('traceroute-result-detail-terminal') + }) + + it('renders markdown snapshot', () => { + const result = formatResultDetail(tracerouteCheckResult, 'md') + expect(result).toMatchSnapshot('traceroute-result-detail-md') + }) + + it('surfaces the diagnostic block for a failed run (terminal)', () => { + const result = stripAnsi(formatResultDetail(tracerouteCheckResult, 'terminal')) + expect(result).toContain('TRACEROUTE RESULT') + expect(result).toContain('unreachable.example.com') + expect(result).toContain('203.0.113.10') + expect(result).toContain('Hops:') + expect(result).toContain('30') + expect(result).toContain('Reached:') + expect(result).toContain('no') + expect(result).toContain('max-hops') + expect(result).toContain('HOPS') + expect(result).toContain('gateway.local') + expect(result).toContain('loss 100%') + }) + + it('surfaces the diagnostic block in markdown', () => { + const result = formatResultDetail(tracerouteCheckResult, 'md') + expect(result).toContain('## Traceroute Result') + expect(result).toContain('**Hops:** 30') + expect(result).toContain('**Truncated:** max-hops') + }) + }) + + describe('gRPC check result', () => { + it('renders terminal snapshot', () => { + const result = stripAnsi(formatResultDetail(grpcCheckResult, 'terminal')) + expect(result).toMatchSnapshot('grpc-result-detail-terminal') + }) + + it('renders markdown snapshot', () => { + const result = formatResultDetail(grpcCheckResult, 'md') + expect(result).toMatchSnapshot('grpc-result-detail-md') + }) + + it('surfaces the diagnostic block for a failed run (terminal)', () => { + const result = stripAnsi(formatResultDetail(grpcCheckResult, 'terminal')) + expect(result).toContain('GRPC RESULT') + expect(result).toContain('grpc.example.com') + expect(result).toContain('grpc.health.v1.Health/Check') + expect(result).toContain('Status:') + expect(result).toContain('14 connection refused') + expect(result).toContain('Health:') + expect(result).toContain('NOT_SERVING') + expect(result).toContain('grpc.health.v1.Health/Watch') + expect(result).toContain('content-type') + }) + + it('surfaces the diagnostic block in markdown', () => { + const result = formatResultDetail(grpcCheckResult, 'md') + expect(result).toContain('## gRPC Result') + expect(result).toContain('**Status:** 14 connection refused') + expect(result).toContain('**Health:** NOT_SERVING') + }) + }) + + describe('SSL check result', () => { + it('renders terminal snapshot', () => { + const result = stripAnsi(formatResultDetail(sslCheckResult, 'terminal')) + expect(result).toMatchSnapshot('ssl-result-detail-terminal') + }) + + it('renders markdown snapshot', () => { + const result = formatResultDetail(sslCheckResult, 'md') + expect(result).toMatchSnapshot('ssl-result-detail-md') + }) + + it('surfaces the diagnostic block for a failed run (terminal)', () => { + const result = stripAnsi(formatResultDetail(sslCheckResult, 'terminal')) + expect(result).toContain('SSL RESULT') + expect(result).toContain('TLS 1.3') + expect(result).toContain('TLS_AES_256_GCM_SHA384') + expect(result).toContain('Expires in:') + expect(result).toContain('expired 5 day(s) ago') + expect(result).toContain('Chain trusted:') + expect(result).toContain('Baseline:') + expect(result).toContain('FAIL') + expect(result).toContain('grade C') + expect(result).toContain('Failure:') + expect(result).toContain('expired') + }) + + it('surfaces the diagnostic block in markdown', () => { + const result = formatResultDetail(sslCheckResult, 'md') + expect(result).toContain('## SSL Result') + expect(result).toContain('**Expires in:** expired 5 day(s) ago') + expect(result).toContain('**Failure:** expired') + }) + }) }) diff --git a/packages/cli/src/formatters/batch-stats.ts b/packages/cli/src/formatters/batch-stats.ts index 0d54bcd8f..2a64053bd 100644 --- a/packages/cli/src/formatters/batch-stats.ts +++ b/packages/cli/src/formatters/batch-stats.ts @@ -8,7 +8,7 @@ import type { QuickRange } from '../rest/analytics.js' export type StatsRow = CheckWithStatus & { analytics?: BatchAnalyticsResult } -const TIMING_TYPES = new Set(['API', 'BROWSER', 'PLAYWRIGHT', 'MULTI_STEP', 'URL', 'TCP', 'DNS']) +const TIMING_TYPES = new Set(['API', 'BROWSER', 'PLAYWRIGHT', 'MULTI_STEP', 'URL', 'TCP', 'DNS', 'GRPC', 'SSL', 'TRACEROUTE']) const ICMP_TYPE = 'ICMP' const DASH = '—' diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 6d3fa2175..c3448ce8a 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -9,6 +9,9 @@ import type { AgenticAssertion, AgenticSuggestion, AgenticStep, + TracerouteCheckResult, + GrpcCheckResult, + SslCheckResult, } from '../rest/check-results.js' import { type OutputFormat, @@ -98,6 +101,13 @@ function firstErrorMessage (result: CheckResult): string { ?.map(e => e?.error?.message ?? '') .find(m => m.length > 0) if (agenticErr) return agenticErr + const tracerouteErr = result.tracerouteCheckResult?.requestError + if (tracerouteErr) return tracerouteErr + const grpcErr = result.grpcCheckResult?.requestError + ?? result.grpcCheckResult?.response?.grpcStatusMessage + if (grpcErr) return grpcErr + const sslErr = result.sslCheckResult?.requestError ?? result.sslCheckResult?.failureCategory + if (sslErr) return sslErr return '' } @@ -209,6 +219,27 @@ export function formatResultDetail (result: CheckResult, format: OutputFormat): parts.push(subLines.join('\n')) } + if (result.tracerouteCheckResult) { + const subLines = format === 'md' + ? formatTracerouteResultMd(result.tracerouteCheckResult) + : formatTracerouteResultTerminal(result.tracerouteCheckResult) + parts.push(subLines.join('\n')) + } + + if (result.grpcCheckResult) { + const subLines = format === 'md' + ? formatGrpcResultMd(result.grpcCheckResult) + : formatGrpcResultTerminal(result.grpcCheckResult) + parts.push(subLines.join('\n')) + } + + if (result.sslCheckResult) { + const subLines = format === 'md' + ? formatSslResultMd(result.sslCheckResult) + : formatSslResultTerminal(result.sslCheckResult) + parts.push(subLines.join('\n')) + } + return parts.join('\n\n') } @@ -614,6 +645,308 @@ function formatAgenticResultMd (agentic: AgenticCheckResult): string[] { return lines } +// --- Traceroute check result --- +// +// The diagnostic objects (tracerouteCheckResult/grpcCheckResult/sslCheckResult) +// carry documented top-level scalars plus a richer `response` artifact. The +// renderers prefer the top-level scalar but fall back to the artifact so a +// metadata-only uptime result still surfaces something useful. + +function num (...vals: Array): number | undefined { + for (const v of vals) { + if (typeof v === 'number' && !Number.isNaN(v)) return v + } + return undefined +} + +function str (...vals: Array): string | undefined { + for (const v of vals) { + if (typeof v === 'string' && v.length > 0) return v + } + return undefined +} + +function boolFlag (...vals: Array): boolean | undefined { + for (const v of vals) { + if (typeof v === 'boolean') return v + } + return undefined +} + +function yesNo (value: boolean | undefined, { goodWhenTrue = true } = {}): string { + if (value == null) return chalk.dim('—') + const good = goodWhenTrue ? value : !value + const text = value ? 'yes' : 'no' + return good ? chalk.green(text) : chalk.red(text) +} + +// finalHopLatency / RTT objects come straight from the runner artifact, where +// keys are snake_case (`avg_ms`/`best_ms`/`worst_ms`); accept the camelCase +// variants too in case a consumer reshapes the payload. +function formatLatencyStats (obj: unknown): string | undefined { + if (!obj || typeof obj !== 'object') return undefined + const o = obj as Record + const avg = num(o.avgMs, o.avg_ms, o.avg) + const best = num(o.bestMs, o.best_ms, o.best) + const worst = num(o.worstMs, o.worst_ms, o.worst) + const parts = [ + avg != null ? `avg ${formatMs(avg)}` : null, + best != null ? `best ${formatMs(best)}` : null, + worst != null ? `worst ${formatMs(worst)}` : null, + ].filter(Boolean) + return parts.length > 0 ? parts.join(' / ') : undefined +} + +function formatTracerouteResultTerminal (tr: TracerouteCheckResult): string[] { + const lines: string[] = [] + const resp = tr.response ?? {} + + lines.push(heading('TRACEROUTE RESULT', 2, 'terminal')) + + const host = str(resp.hostname) + const ip = str(resp.resolvedIp) + if (host || ip) { + const dest = [host, ip ? `(${ip})` : null].filter(Boolean).join(' ') + lines.push(`${label('Destination:')}${dest}`) + } + const protocol = str(resp.protocol, resp.probeProtocol) + if (protocol) lines.push(`${label('Protocol:')}${protocol}`) + + const totalHops = num(tr.totalHops, resp.totalHops) + if (totalHops != null) lines.push(`${label('Hops:')}${totalHops}`) + + const reached = boolFlag(tr.destinationReached, resp.destinationReached) + lines.push(`${label('Reached:')}${yesNo(reached)}`) + + const truncation = str(resp.truncationReason) + if (truncation) lines.push(`${label('Truncated:')}${chalk.yellow(truncation)}`) + + const finalHop = formatLatencyStats(tr.finalHopLatency ?? resp.finalHopLatency) + if (finalHop) lines.push(`${label('Final hop:')}${finalHop}`) + + const hops = Array.isArray(resp.hops) ? resp.hops : [] + if (hops.length > 0) { + lines.push('') + lines.push(heading('HOPS', 2, 'terminal')) + for (const hop of hops.slice(0, 40)) { + lines.push(` ${formatTracerouteHop(hop)}`) + } + if (hops.length > 40) { + lines.push(chalk.dim(` ... (${hops.length - 40} more hops)`)) + } + } + + if (tr.requestError) { + lines.push('') + lines.push(heading('ERROR', 2, 'terminal')) + lines.push(chalk.red(` ${tr.requestError}`)) + } + + return lines +} + +function formatTracerouteHop (hop: unknown): string { + if (!hop || typeof hop !== 'object') return String(hop) + const h = hop as Record + const n = num(h.hop_number, h.hopNumber, h.number, h.hop) + const addr = str(h.main_ip, h.mainIp, h.ip, h.address) ?? '*' + const host = str(h.main_host, h.mainHost, h.host) + const loss = num(h.loss_percentage, h.lossPercentage, h.loss) + const rtt = formatLatencyStats(h.rtt) + const asn = num(h.asn) + return [ + n != null ? chalk.dim(String(n).padStart(2)) : chalk.dim(' ·'), + host ? `${addr} (${host})` : addr, + loss != null ? `loss ${loss.toFixed(0)}%` : null, + rtt ? `rtt ${rtt}` : null, + asn ? chalk.dim(`AS${asn}`) : null, + ].filter(Boolean).join(' ') +} + +function formatTracerouteResultMd (tr: TracerouteCheckResult): string[] { + const lines: string[] = ['## Traceroute Result'] + const resp = tr.response ?? {} + + const host = str(resp.hostname) + const ip = str(resp.resolvedIp) + if (host || ip) lines.push(`- **Destination:** ${[host, ip ? `(${ip})` : null].filter(Boolean).join(' ')}`) + const totalHops = num(tr.totalHops, resp.totalHops) + if (totalHops != null) lines.push(`- **Hops:** ${totalHops}`) + const reached = boolFlag(tr.destinationReached, resp.destinationReached) + if (reached != null) lines.push(`- **Reached:** ${reached ? 'yes' : 'no'}`) + const truncation = str(resp.truncationReason) + if (truncation) lines.push(`- **Truncated:** ${truncation}`) + const finalHop = formatLatencyStats(tr.finalHopLatency ?? resp.finalHopLatency) + if (finalHop) lines.push(`- **Final hop:** ${finalHop}`) + if (tr.requestError) lines.push(`- **Error:** ${tr.requestError}`) + + return lines +} + +// --- gRPC check result --- + +function formatGrpcResultTerminal (grpc: GrpcCheckResult): string[] { + const lines: string[] = [] + const resp = grpc.response ?? {} + + lines.push(heading('GRPC RESULT', 2, 'terminal')) + + const host = str(resp.host) + const ip = str(resp.resolvedIp) + const port = num(resp.port) + if (host || ip || port != null) { + const target = [ + host ?? ip, + port != null ? `:${port}` : '', + ].filter(Boolean).join('') + lines.push(`${label('Target:')}${target}`) + } + const method = str(resp.grpcMethod) + if (method) lines.push(`${label('Method:')}${method}`) + const mode = str(resp.grpcMode) + if (mode) lines.push(`${label('Mode:')}${mode}`) + + const code = num(grpc.grpcStatusCode, resp.grpcStatusCode) + const statusMsg = str(resp.grpcStatusMessage) + if (code != null) { + const codeStr = code === 0 ? chalk.green(String(code)) : chalk.red(String(code)) + lines.push(`${label('Status:')}${codeStr}${statusMsg ? ` ${statusMsg}` : ''}`) + } + + const healthLabel = str(resp.healthStatusLabel) + const healthNum = num(grpc.healthStatus, resp.healthStatus) + if (healthLabel || healthNum != null) { + const display = healthLabel ?? String(healthNum) + const colored = healthLabel === 'SERVING' ? chalk.green(display) : chalk.red(display) + lines.push(`${label('Health:')}${colored}`) + } + + const responseMessage = str(resp.responseMessage) + if (responseMessage) lines.push(`${label('Response:')}${truncateSingleLine(responseMessage, 200)}`) + + const methods = Array.isArray(resp.discoveredMethods) ? resp.discoveredMethods : [] + if (methods.length > 0) { + lines.push(`${label('Methods:')}${methods.slice(0, 20).join(', ')}${methods.length > 20 ? ', …' : ''}`) + } + + const metadata = Array.isArray(resp.metadata) ? resp.metadata : [] + if (metadata.length > 0) { + lines.push(`${label('Metadata:')}`) + for (const entry of metadata.slice(0, 20)) { + lines.push(` ${formatKeyValueEntry(entry)}`) + } + } + + if (grpc.requestError) { + lines.push('') + lines.push(heading('ERROR', 2, 'terminal')) + lines.push(chalk.red(` ${grpc.requestError}`)) + } + + return lines +} + +function formatKeyValueEntry (entry: unknown): string { + if (!entry || typeof entry !== 'object') return String(entry) + const e = entry as Record + const key = str(e.key, e.name) ?? '(key)' + const value = e.value != null ? String(e.value) : '' + return `${chalk.dim(key + ':')} ${value}` +} + +function formatGrpcResultMd (grpc: GrpcCheckResult): string[] { + const lines: string[] = ['## gRPC Result'] + const resp = grpc.response ?? {} + + const code = num(grpc.grpcStatusCode, resp.grpcStatusCode) + const statusMsg = str(resp.grpcStatusMessage) + if (code != null) lines.push(`- **Status:** ${code}${statusMsg ? ` ${statusMsg}` : ''}`) + const healthLabel = str(resp.healthStatusLabel) + if (healthLabel) lines.push(`- **Health:** ${healthLabel}`) + const method = str(resp.grpcMethod) + if (method) lines.push(`- **Method:** ${method}`) + const responseMessage = str(resp.responseMessage) + if (responseMessage) lines.push(`- **Response:** \`${truncateSingleLine(responseMessage, 200)}\``) + const methods = Array.isArray(resp.discoveredMethods) ? resp.discoveredMethods : [] + if (methods.length > 0) lines.push(`- **Discovered methods:** ${methods.join(', ')}`) + if (grpc.requestError) lines.push(`- **Error:** ${grpc.requestError}`) + + return lines +} + +// --- SSL check result --- + +function formatSslResultTerminal (ssl: SslCheckResult): string[] { + const lines: string[] = [] + const resp = ssl.response ?? {} + + lines.push(heading('SSL RESULT', 2, 'terminal')) + + const ip = str(resp.resolvedIp) + if (ip) lines.push(`${label('Resolved IP:')}${ip}`) + + const tls = str(ssl.tlsVersion, resp.protocol) + const cipher = str(ssl.cipherSuite, resp.cipherSuite) + if (tls || cipher) lines.push(`${label('TLS:')}${[tls, cipher].filter(Boolean).join(' / ')}`) + + const days = num(ssl.daysUntilExpiry, resp.daysUntilExpiry) + if (days != null) { + const text = days < 0 ? `expired ${-days} day(s) ago` : `${days} day(s)` + lines.push(`${label('Expires in:')}${days <= 0 ? chalk.red(text) : days < 14 ? chalk.yellow(text) : chalk.green(text)}`) + } + + const handshake = num(ssl.handshakeTimeMs, resp.handshakeTimeMs) + if (handshake != null) lines.push(`${label('Handshake:')}${formatMs(handshake)}`) + + const chainTrusted = boolFlag(ssl.chainTrusted, resp.chainTrusted) + if (chainTrusted != null) lines.push(`${label('Chain trusted:')}${yesNo(chainTrusted)}`) + const hostnameVerified = boolFlag(ssl.hostnameVerified, resp.hostnameVerified) + if (hostnameVerified != null) lines.push(`${label('Hostname:')}${yesNo(hostnameVerified)}`) + + const verdict = str(ssl.baselineVerdict) + const grade = str(ssl.baselineGrade) + if (verdict || grade) { + const verdictStr = verdict ? (verdict === 'PASS' ? chalk.green(verdict) : chalk.red(verdict)) : '' + lines.push(`${label('Baseline:')}${[verdictStr, grade ? `grade ${grade}` : ''].filter(Boolean).join(' ')}`) + } + + const failure = str(ssl.failureCategory) + if (failure) lines.push(`${label('Failure:')}${chalk.red(failure)}`) + + if (ssl.requestError) { + lines.push('') + lines.push(heading('ERROR', 2, 'terminal')) + lines.push(chalk.red(` ${ssl.requestError}`)) + } + + return lines +} + +function formatSslResultMd (ssl: SslCheckResult): string[] { + const lines: string[] = ['## SSL Result'] + const resp = ssl.response ?? {} + + const tls = str(ssl.tlsVersion, resp.protocol) + const cipher = str(ssl.cipherSuite, resp.cipherSuite) + if (tls || cipher) lines.push(`- **TLS:** ${[tls, cipher].filter(Boolean).join(' / ')}`) + const days = num(ssl.daysUntilExpiry, resp.daysUntilExpiry) + if (days != null) lines.push(`- **Expires in:** ${days < 0 ? `expired ${-days} day(s) ago` : `${days} day(s)`}`) + const handshake = num(ssl.handshakeTimeMs, resp.handshakeTimeMs) + if (handshake != null) lines.push(`- **Handshake:** ${formatMs(handshake)}`) + const chainTrusted = boolFlag(ssl.chainTrusted, resp.chainTrusted) + if (chainTrusted != null) lines.push(`- **Chain trusted:** ${chainTrusted ? 'yes' : 'no'}`) + const hostnameVerified = boolFlag(ssl.hostnameVerified, resp.hostnameVerified) + if (hostnameVerified != null) lines.push(`- **Hostname verified:** ${hostnameVerified ? 'yes' : 'no'}`) + const verdict = str(ssl.baselineVerdict) + const grade = str(ssl.baselineGrade) + if (verdict || grade) lines.push(`- **Baseline:** ${[verdict, grade ? `grade ${grade}` : ''].filter(Boolean).join(' ')}`) + const failure = str(ssl.failureCategory) + if (failure) lines.push(`- **Failure:** ${failure}`) + if (ssl.requestError) lines.push(`- **Error:** ${ssl.requestError}`) + + return lines +} + // --- Shared internal helpers --- function colorStatus (code: number): string { diff --git a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap index ac84bcfaf..9a6ff4f78 100644 --- a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap +++ b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap @@ -246,3 +246,38 @@ exports[`formatCheckResult() > Browser Check result > formats a Browser Check re `; exports[`formatCheckResult() > Browser Check result > formats a basic Browser Check result > browser-check-result-basic-format 1`] = `""`; + +exports[`formatCheckResult() > SSL Check result > formats a failing SSL Check result > ssl-check-result-format 1`] = ` +"──SSL Response────────────────────────────────────────────────────────────── +Resolved IP: 203.0.113.30 +TLS: TLS 1.3 / TLS_AES_256_GCM_SHA384 +Expires in: expired 5 day(s) ago +Handshake: 48.200ms +Chain Trusted: no +Hostname Verified: no" +`; + +exports[`formatCheckResult() > Traceroute Check result > formats a failing Traceroute Check result > traceroute-check-result-format 1`] = ` +"──Traceroute Response─────────────────────────────────────────────────────── +Destination: unreachable.example.com (203.0.113.10) +Protocol: TCP +Total Hops: 30 +Destination Reached: no +Truncated: max-hops +Final Hop Latency: avg 24.100ms / best 22.000ms / worst 31.400ms +Hops: + 1 10.0.0.1 (gateway.local) loss 0% rtt avg 1.200ms / best 1.000ms / worst 1.500ms + 2 198.51.100.5 loss 100%" +`; + +exports[`formatCheckResult() > gRPC Check result > formats a failing gRPC Check result > grpc-check-result-format 1`] = ` +"──gRPC Response───────────────────────────────────────────────────────────── +Target: grpc.example.com:443 +Method: grpc.health.v1.Health/Check +Mode: HEALTH +Status: 14 connection refused +Health: NOT_SERVING +Discovered Methods: grpc.health.v1.Health/Check, grpc.health.v1.Health/Watch +Metadata: + content-type: application/grpc" +`; diff --git a/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts b/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts new file mode 100644 index 000000000..4ad4c6742 --- /dev/null +++ b/packages/cli/src/reporters/__tests__/fixtures/uptime-check-results.ts @@ -0,0 +1,122 @@ +// Runner-path (`checkly test`) result fixtures for the TRACEROUTE/GRPC/SSL +// uptime monitor types. Each is a failing run whose diagnostic data lives in +// `checkRunData.response` — the artifact `formatCheckResult` and the JSON +// reporter read. + +export const tracerouteCheckResult = { + logicalId: 'test-traceroute-check-1', + sourceFile: 'src/uptime/traceroute.check.ts', + sourceInfo: { + checkRunId: 'tr-run-1', + sequenceId: 'tr-seq-1', + }, + checkRunId: 'tr-run-1', + name: 'Traceroute to unreachable host', + checkType: 'TRACEROUTE', + hasErrors: false, + hasFailures: true, + isDegraded: false, + attempts: 1, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:01.000Z', + responseTime: 1000, + checkRunData: { + requestError: null, + assertions: [], + response: { + hostname: 'unreachable.example.com', + resolvedIp: '203.0.113.10', + totalHops: 30, + destinationReached: false, + truncationReason: 'max-hops', + protocol: 'TCP', + finalHopLatency: { avg_ms: 24.1, best_ms: 22.0, worst_ms: 31.4 }, + hops: [ + { hop_number: 1, main_ip: '10.0.0.1', main_host: 'gateway.local', loss_percentage: 0, rtt: { avg: 1.2, best: 1.0, worst: 1.5 } }, + { hop_number: 2, main_ip: '198.51.100.5', loss_percentage: 100, rtt: null, asn: 64500 }, + ], + }, + }, + logs: [], + scheduleError: '', + runError: '', +} + +export const grpcCheckResult = { + logicalId: 'test-grpc-check-1', + sourceFile: 'src/uptime/grpc.check.ts', + sourceInfo: { + checkRunId: 'grpc-run-1', + sequenceId: 'grpc-seq-1', + }, + checkRunId: 'grpc-run-1', + name: 'gRPC health probe', + checkType: 'GRPC', + hasErrors: false, + hasFailures: true, + isDegraded: false, + attempts: 1, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:00.090Z', + responseTime: 90, + checkRunData: { + requestError: null, + assertions: [], + response: { + grpcMode: 'HEALTH', + host: 'grpc.example.com', + resolvedIp: '203.0.113.20', + port: 443, + grpcMethod: 'grpc.health.v1.Health/Check', + responseMessage: '', + grpcStatusCode: 14, + grpcStatusMessage: 'connection refused', + healthStatus: 2, + healthStatusLabel: 'NOT_SERVING', + metadata: [{ key: 'content-type', value: 'application/grpc' }], + discoveredMethods: ['grpc.health.v1.Health/Check', 'grpc.health.v1.Health/Watch'], + }, + }, + logs: [], + scheduleError: '', + runError: '', +} + +export const sslCheckResult = { + logicalId: 'test-ssl-check-1', + sourceFile: 'src/uptime/ssl.check.ts', + sourceInfo: { + checkRunId: 'ssl-run-1', + sequenceId: 'ssl-seq-1', + }, + checkRunId: 'ssl-run-1', + name: 'SSL certificate expiry', + checkType: 'SSL', + hasErrors: false, + hasFailures: true, + isDegraded: false, + attempts: 1, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:00.048Z', + responseTime: 48, + checkRunData: { + requestError: null, + assertions: [], + response: { + resolvedIp: '203.0.113.30', + protocol: 'TLS 1.3', + cipherSuite: 'TLS_AES_256_GCM_SHA384', + handshakeTimeMs: 48.2, + hostnameVerified: false, + chainTrusted: false, + daysUntilExpiry: -5, + ocspStapled: false, + }, + }, + logs: [], + scheduleError: '', + runError: '', +} diff --git a/packages/cli/src/reporters/__tests__/json-builder.spec.ts b/packages/cli/src/reporters/__tests__/json-builder.spec.ts index dca9d33fa..58404f074 100644 --- a/packages/cli/src/reporters/__tests__/json-builder.spec.ts +++ b/packages/cli/src/reporters/__tests__/json-builder.spec.ts @@ -1,7 +1,14 @@ import { describe, expect, test, vi } from 'vitest' -import { JsonBuilder } from '../json.js' +import { JsonBuilder, buildPerTypeDiagnostics } from '../json.js' import { generateMapAndTestResultIds } from './helpers.js' +import { checkFilesMap } from '../abstract-list.js' +import { SequenceId } from '../../services/abstract-check-runner.js' +import { + tracerouteCheckResult, + grpcCheckResult, + sslCheckResult, +} from './fixtures/uptime-check-results.js' vi.mock('../../rest/api', () => ({ getDefaults: () => ({ @@ -45,4 +52,80 @@ describe('JsonBuilder', () => { }).render() expect(json).toMatchSnapshot('json-with-assets-links') }) + + test('emits per-type diagnostics for failed traceroute/grpc/ssl runs', () => { + const map: checkFilesMap = new Map([ + ['src/uptime/diagnostics.check.ts', new Map([ + [tracerouteCheckResult.sourceInfo.sequenceId as SequenceId, { + result: tracerouteCheckResult as any, + titleString: tracerouteCheckResult.name, + testResultId: undefined, + numRetries: 0, + links: undefined, + }], + [grpcCheckResult.sourceInfo.sequenceId as SequenceId, { + result: grpcCheckResult as any, + titleString: grpcCheckResult.name, + testResultId: undefined, + numRetries: 0, + links: undefined, + }], + [sslCheckResult.sourceInfo.sequenceId as SequenceId, { + result: sslCheckResult as any, + titleString: sslCheckResult.name, + testResultId: undefined, + numRetries: 0, + links: undefined, + }], + ])], + ]) + + const json = new JsonBuilder({ + testSessionId: undefined, + numChecks: 3, + runLocation, + checkFilesMap: map, + }).render() + const parsed = JSON.parse(json) + + const byType = Object.fromEntries(parsed.checks.map((c: any) => [c.checkType, c])) + + expect(byType.TRACEROUTE.result).toBe('Fail') + expect(byType.TRACEROUTE.diagnostics.tracerouteCheckResult.totalHops).toBe(30) + expect(byType.TRACEROUTE.diagnostics.tracerouteCheckResult.destinationReached).toBe(false) + expect(byType.TRACEROUTE.diagnostics.tracerouteCheckResult.truncationReason).toBe('max-hops') + expect(byType.TRACEROUTE.diagnostics.tracerouteCheckResult.response.hops).toHaveLength(2) + + expect(byType.GRPC.diagnostics.grpcCheckResult.grpcStatusCode).toBe(14) + expect(byType.GRPC.diagnostics.grpcCheckResult.grpcStatusMessage).toBe('connection refused') + expect(byType.GRPC.diagnostics.grpcCheckResult.healthStatusLabel).toBe('NOT_SERVING') + expect(byType.GRPC.diagnostics.grpcCheckResult.discoveredMethods).toContain('grpc.health.v1.Health/Watch') + + expect(byType.SSL.diagnostics.sslCheckResult.tlsVersion).toBe('TLS 1.3') + expect(byType.SSL.diagnostics.sslCheckResult.daysUntilExpiry).toBe(-5) + expect(byType.SSL.diagnostics.sslCheckResult.chainTrusted).toBe(false) + expect(byType.SSL.diagnostics.sslCheckResult.hostnameVerified).toBe(false) + }) +}) + +describe('buildPerTypeDiagnostics()', () => { + test('returns undefined for non-diagnostic check types', () => { + expect(buildPerTypeDiagnostics({ checkType: 'API', checkRunData: { response: {} } })).toBeUndefined() + }) + test('returns undefined when no artifact or error is present', () => { + expect(buildPerTypeDiagnostics({ checkType: 'SSL' })).toBeUndefined() + }) + test('emits a sparse diagnostic from a requestError alone', () => { + const diag = buildPerTypeDiagnostics({ checkType: 'TRACEROUTE', checkRunData: { requestError: 'dns failure' } }) + expect(diag).toEqual({ + tracerouteCheckResult: { + totalHops: null, + destinationReached: null, + finalHopLatency: null, + truncationReason: null, + requestError: 'dns failure', + response: null, + }, + }) + }) }) diff --git a/packages/cli/src/reporters/__tests__/util.spec.ts b/packages/cli/src/reporters/__tests__/util.spec.ts index 1f232e7a2..004ea5a47 100644 --- a/packages/cli/src/reporters/__tests__/util.spec.ts +++ b/packages/cli/src/reporters/__tests__/util.spec.ts @@ -6,6 +6,11 @@ import { simpleCheckFixture } from './fixtures/simple-check.js' import { apiCheckResult } from './fixtures/api-check-result.js' import { browserCheckResult } from './fixtures/browser-check-result.js' import { agenticCheckResult, agenticCheckResultWithFailures } from './fixtures/agentic-check-result.js' +import { + tracerouteCheckResult, + grpcCheckResult, + sslCheckResult, +} from './fixtures/uptime-check-results.js' function stripAnsi (input: string): string { return input.replace( @@ -129,6 +134,50 @@ describe('formatCheckResult()', () => { expect(output).not.toContain('Assertions') }) }) + describe('Traceroute Check result', () => { + it('formats a failing Traceroute Check result', () => { + expect(stripAnsi(formatCheckResult(tracerouteCheckResult))) + .toMatchSnapshot('traceroute-check-result-format') + }) + it('surfaces the diagnostic block', () => { + const output = stripAnsi(formatCheckResult(tracerouteCheckResult)) + expect(output).toContain('Traceroute Response') + expect(output).toContain('unreachable.example.com') + expect(output).toContain('Total Hops: 30') + expect(output).toContain('Destination Reached: no') + expect(output).toContain('Truncated: max-hops') + expect(output).toContain('gateway.local') + expect(output).toContain('loss 100%') + }) + }) + describe('gRPC Check result', () => { + it('formats a failing gRPC Check result', () => { + expect(stripAnsi(formatCheckResult(grpcCheckResult))) + .toMatchSnapshot('grpc-check-result-format') + }) + it('surfaces the diagnostic block', () => { + const output = stripAnsi(formatCheckResult(grpcCheckResult)) + expect(output).toContain('gRPC Response') + expect(output).toContain('grpc.example.com:443') + expect(output).toContain('Status: 14 connection refused') + expect(output).toContain('Health: NOT_SERVING') + expect(output).toContain('grpc.health.v1.Health/Watch') + }) + }) + describe('SSL Check result', () => { + it('formats a failing SSL Check result', () => { + expect(stripAnsi(formatCheckResult(sslCheckResult))) + .toMatchSnapshot('ssl-check-result-format') + }) + it('surfaces the diagnostic block', () => { + const output = stripAnsi(formatCheckResult(sslCheckResult)) + expect(output).toContain('SSL Response') + expect(output).toContain('TLS: TLS 1.3 / TLS_AES_256_GCM_SHA384') + expect(output).toContain('Expires in: expired 5 day(s) ago') + expect(output).toContain('Chain Trusted: no') + expect(output).toContain('Hostname Verified: no') + }) + }) }) describe('resultToCheckStatus()', () => { diff --git a/packages/cli/src/reporters/json.ts b/packages/cli/src/reporters/json.ts index b448f4c0b..6d44beb46 100644 --- a/packages/cli/src/reporters/json.ts +++ b/packages/cli/src/reporters/json.ts @@ -7,6 +7,62 @@ import { CheckStatus, getTestSessionUrl, printLn, resultToCheckStatus } from './ const outputFile = './checkly-json-report.json' +// Per-type failure-debug diagnostics for the three uptime monitor types, shaped +// like the public check-results fields (tracerouteCheckResult/grpcCheckResult/ +// sslCheckResult). Sourced from the runner artifact (`checkRunData`) so +// `checkly test --reporter json` emits the data an agent needs to root-cause a +// failed run, not just `result: 'Fail'`. Returns undefined for other types or +// when no diagnostic artifact is present. +export function buildPerTypeDiagnostics (result: any): Record | undefined { + const data = result?.checkRunData + const response = data?.response + const requestError = data?.requestError ?? null + if (!response && !requestError) { + return undefined + } + + switch (result?.checkType) { + case 'TRACEROUTE': + return { + tracerouteCheckResult: { + totalHops: response?.totalHops ?? null, + destinationReached: response?.destinationReached ?? null, + finalHopLatency: response?.finalHopLatency ?? null, + truncationReason: response?.truncationReason ?? null, + requestError, + response: response ?? null, + }, + } + case 'GRPC': + return { + grpcCheckResult: { + grpcStatusCode: response?.grpcStatusCode ?? null, + grpcStatusMessage: response?.grpcStatusMessage ?? null, + healthStatus: response?.healthStatus ?? null, + healthStatusLabel: response?.healthStatusLabel ?? null, + discoveredMethods: response?.discoveredMethods ?? null, + requestError, + response: response ?? null, + }, + } + case 'SSL': + return { + sslCheckResult: { + tlsVersion: response?.protocol ?? null, + cipherSuite: response?.cipherSuite ?? null, + daysUntilExpiry: response?.daysUntilExpiry ?? null, + handshakeTimeMs: response?.handshakeTimeMs ?? null, + chainTrusted: response?.chainTrusted ?? null, + hostnameVerified: response?.hostnameVerified ?? null, + requestError, + response: response ?? null, + }, + } + default: + return undefined + } +} + type JsonBuilderOptions = { testSessionId?: string numChecks: number @@ -57,6 +113,11 @@ export class JsonBuilder { check.filename = result.sourceFile } + const diagnostics = buildPerTypeDiagnostics(result) + if (diagnostics) { + check.diagnostics = diagnostics + } + if (this.testSessionId && testResultId) { check.link = `${getTestSessionUrl(this.testSessionId)}/results/${testResultId}` } diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 533ba4909..7d6754340 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -235,6 +235,63 @@ export function formatCheckResult (checkResult: any) { ]) } } + if (checkResult.checkType === 'TRACEROUTE') { + if (checkResult.checkRunData?.requestError) { + result.push([ + formatSectionTitle('Request Error'), + checkResult.checkRunData.requestError, + ]) + } else if (checkResult.checkRunData?.response) { + result.push([ + formatSectionTitle('Traceroute Response'), + formatTracerouteResponse(checkResult.checkRunData.response), + ]) + } + if (checkResult.checkRunData?.assertions?.length) { + result.push([ + formatSectionTitle('Assertions'), + formatAssertions(checkResult.checkRunData.assertions), + ]) + } + } + if (checkResult.checkType === 'GRPC') { + if (checkResult.checkRunData?.requestError) { + result.push([ + formatSectionTitle('Request Error'), + checkResult.checkRunData.requestError, + ]) + } else if (checkResult.checkRunData?.response) { + result.push([ + formatSectionTitle('gRPC Response'), + formatGrpcResponse(checkResult.checkRunData.response), + ]) + } + if (checkResult.checkRunData?.assertions?.length) { + result.push([ + formatSectionTitle('Assertions'), + formatAssertions(checkResult.checkRunData.assertions), + ]) + } + } + if (checkResult.checkType === 'SSL') { + if (checkResult.checkRunData?.requestError) { + result.push([ + formatSectionTitle('Request Error'), + checkResult.checkRunData.requestError, + ]) + } else if (checkResult.checkRunData?.response) { + result.push([ + formatSectionTitle('SSL Response'), + formatSslResponse(checkResult.checkRunData.response), + ]) + } + if (checkResult.checkRunData?.assertions?.length) { + result.push([ + formatSectionTitle('Assertions'), + formatAssertions(checkResult.checkRunData.assertions), + ]) + } + } if (checkResult.checkType === 'ICMP') { if (checkResult.checkRunData?.requestError) { result.push([ @@ -589,6 +646,90 @@ function formatIcmpResponse (response: ICMPResponse) { ].filter(Boolean).join('\n') } +// Traceroute / gRPC / SSL diagnostics for the `checkly test` runner path. The +// runner artifact (`checkRunData.response`) mirrors the public check-results +// `response` sub-object; keys come straight off the go-runner (snake_case for +// traceroute hops/latency), so read defensively. + +function latencyStats (obj: any): string | undefined { + if (!obj || typeof obj !== 'object') return undefined + const avg = obj.avgMs ?? obj.avg_ms ?? obj.avg + const best = obj.bestMs ?? obj.best_ms ?? obj.best + const worst = obj.worstMs ?? obj.worst_ms ?? obj.worst + const parts = [ + typeof avg === 'number' ? `avg ${formatLatency(avg)}` : undefined, + typeof best === 'number' ? `best ${formatLatency(best)}` : undefined, + typeof worst === 'number' ? `worst ${formatLatency(worst)}` : undefined, + ].filter(Boolean) + return parts.length ? parts.join(' / ') : undefined +} + +function formatTracerouteResponse (response: any) { + const host = response.hostname + const ip = response.resolvedIp + const hops = Array.isArray(response.hops) ? response.hops : [] + const finalHop = latencyStats(response.finalHopLatency) + const hopLines = hops.slice(0, 40).map((hop: any) => { + const n = hop.hop_number ?? hop.hopNumber ?? hop.number + const addr = hop.main_ip ?? hop.mainIp ?? hop.ip ?? '*' + const hopHost = hop.main_host ?? hop.mainHost ?? hop.host + const loss = hop.loss_percentage ?? hop.lossPercentage ?? hop.loss + const rtt = latencyStats(hop.rtt) + return [ + ` ${n != null ? String(n).padStart(2) : ' .'}`, + hopHost ? `${addr} (${hopHost})` : addr, + typeof loss === 'number' ? `loss ${loss.toFixed(0)}%` : undefined, + rtt ? `rtt ${rtt}` : undefined, + ].filter(Boolean).join(' ') + }) + return [ + host || ip ? `Destination: ${[host, ip ? `(${ip})` : ''].filter(Boolean).join(' ')}` : undefined, + response.protocol ? `Protocol: ${response.protocol}` : undefined, + response.totalHops != null ? `Total Hops: ${response.totalHops}` : undefined, + response.destinationReached != null ? `Destination Reached: ${response.destinationReached ? 'yes' : 'no'}` : undefined, + response.truncationReason ? `Truncated: ${response.truncationReason}` : undefined, + finalHop ? `Final Hop Latency: ${finalHop}` : undefined, + hopLines.length ? 'Hops:' : undefined, + ...hopLines, + ].filter(Boolean).join('\n') +} + +function formatGrpcResponse (response: any) { + const target = [response.host ?? response.resolvedIp, response.port ? `:${response.port}` : ''].filter(Boolean).join('') + const methods = Array.isArray(response.discoveredMethods) ? response.discoveredMethods : [] + const metadata = Array.isArray(response.metadata) ? response.metadata : [] + const metadataLines = metadata.slice(0, 20).map((m: any) => ` ${m.key ?? m.name ?? '(key)'}: ${m.value ?? ''}`) + return [ + target ? `Target: ${target}` : undefined, + response.grpcMethod ? `Method: ${response.grpcMethod}` : undefined, + response.grpcMode ? `Mode: ${response.grpcMode}` : undefined, + response.grpcStatusCode != null + ? `Status: ${response.grpcStatusCode}${response.grpcStatusMessage ? ` ${response.grpcStatusMessage}` : ''}` + : undefined, + response.healthStatusLabel ? `Health: ${response.healthStatusLabel}` : undefined, + response.responseMessage ? `Response: ${response.responseMessage}` : undefined, + methods.length ? `Discovered Methods: ${methods.join(', ')}` : undefined, + metadata.length ? 'Metadata:' : undefined, + ...metadataLines, + ].filter(Boolean).join('\n') +} + +function formatSslResponse (response: any) { + const days = response.daysUntilExpiry + return [ + response.resolvedIp ? `Resolved IP: ${response.resolvedIp}` : undefined, + response.protocol || response.cipherSuite + ? `TLS: ${[response.protocol, response.cipherSuite].filter(Boolean).join(' / ')}` + : undefined, + typeof days === 'number' + ? `Expires in: ${days < 0 ? `expired ${-days} day(s) ago` : `${days} day(s)`}` + : undefined, + typeof response.handshakeTimeMs === 'number' ? `Handshake: ${formatLatency(response.handshakeTimeMs)}` : undefined, + response.chainTrusted != null ? `Chain Trusted: ${response.chainTrusted ? 'yes' : 'no'}` : undefined, + response.hostnameVerified != null ? `Hostname Verified: ${response.hostnameVerified ? 'yes' : 'no'}` : undefined, + ].filter(Boolean).join('\n') +} + function formatConnectionError (error: any) { if (isDNSLookupFailureError(error)) { const message = [ diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts index 744dc25eb..66373ec87 100644 --- a/packages/cli/src/rest/check-results.ts +++ b/packages/cli/src/rest/check-results.ts @@ -22,6 +22,9 @@ export interface CheckResult { browserCheckResult?: BrowserCheckResult | null multiStepCheckResult?: MultiStepCheckResult | null agenticCheckResult?: AgenticCheckResult | null + tracerouteCheckResult?: TracerouteCheckResult | null + grpcCheckResult?: GrpcCheckResult | null + sslCheckResult?: SslCheckResult | null } // --- API check result --- @@ -168,6 +171,91 @@ export interface AgenticCheckResult { jobAssets?: string[] | null } +// --- Traceroute / gRPC / SSL check results --- +// +// Failure-debug diagnostics for the three uptime monitor types, mirroring the +// typed fields the public check-results response carries (see the backend +// `check-results/schemas.js` CheckResultTraceroute/Grpc/Ssl schemas). The +// documented top-level scalars are typed; the open runner sub-objects +// (`timingPhases`, `request`, `assertions`, `certificate`, `securityBaseline`, +// per-hop entries) stay `Record` / arrays so no runner field +// is silently dropped. Every field is optional/nullable: a metadata-only uptime +// result still emits a (sparse) diagnostic. + +export interface TracerouteCheckResult { + totalHops?: number | null + destinationReached?: boolean | null + finalHopLatency?: Record | null + timingPhases?: Record | null + requestError?: string | null + request?: Record | null + assertions?: Array> | null + response?: { + hostname?: string | null + resolvedIp?: string | null + totalHops?: number | null + destinationReached?: boolean | null + truncationReason?: string | null + finalHopLatency?: Record | null + hops?: Array> | null + protocol?: string | null + probeProtocol?: string | null + } | null +} + +export interface GrpcCheckResult { + grpcStatusCode?: number | null + healthStatus?: number | null + timingPhases?: Record | null + requestError?: string | null + request?: Record | null + assertions?: Array> | null + response?: { + grpcMode?: string | null + host?: string | null + resolvedIp?: string | null + port?: number | null + grpcMethod?: string | null + responseMessage?: string | null + grpcStatusCode?: number | null + grpcStatusMessage?: string | null + healthStatus?: number | null + healthStatusLabel?: string | null + metadata?: Array> | null + discoveredMethods?: string[] | null + requestError?: string | null + timingPhases?: Record | null + } | null +} + +export interface SslCheckResult { + tlsVersion?: string | null + cipherSuite?: string | null + daysUntilExpiry?: number | null + handshakeTimeMs?: number | null + chainTrusted?: boolean | null + hostnameVerified?: boolean | null + baselineVerdict?: string | null + baselineGrade?: string | null + failureCategory?: string | null + requestError?: string | null + request?: Record | null + assertions?: Array> | null + response?: { + resolvedIp?: string | null + protocol?: string | null + cipherSuite?: string | null + handshakeTimeMs?: number | null + hostnameVerified?: boolean | null + chainTrusted?: boolean | null + daysUntilExpiry?: number | null + ocspStapled?: boolean | null + securityBaseline?: Record | null + certificate?: Record | null + chain?: Array> | null + } | null +} + export interface CheckResultsPage { length: number entries: CheckResult[] @@ -197,6 +285,9 @@ export type CheckResultField = | 'browserCheckResult' | 'multiStepCheckResult' | 'agenticCheckResult' + | 'tracerouteCheckResult' + | 'grpcCheckResult' + | 'sslCheckResult' | 'playwrightCheckResult' | 'checkRunId' | 'attempts' From 0ea88b5d34afdd7c73e0ae93c9285e583460b2a1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 26 Jun 2026 13:41:18 +0000 Subject: [PATCH 02/10] feat(constructs): add GrpcMonitor, SslMonitor, TracerouteMonitor IaC constructs Implement the missing CLI constructs so users can author and deploy gRPC, SSL and Traceroute uptime monitors via checkly/constructs, and so backend CLI export templates that import these classes compile. Constructs follow the DNS/TCP/ICMP monitor pattern (extend Monitor, register with Session, validate, synthesize): - GrpcMonitor / GrpcRequest / GrpcConfig / GrpcMetadata + GrpcAssertionBuilder (RESPONSE_TIME, GRPC_STATUS_CODE, GRPC_HEALTHCHECK_STATUS, GRPC_RESPONSE, GRPC_METADATA). checkType GRPC, top-level degraded/maxResponseTime (<=30000). - SslMonitor / SslRequest / SslConfig / SecurityBaseline + SslAssertionBuilder (cert expiry, chain, hostname, TLS version, cipher, key size, etc). checkType SSL; response-time limits live in sslConfig (degraded/maxResponseTimeMs). - TracerouteMonitor / TracerouteRequest + TracerouteAssertionBuilder (RESPONSE_TIME, HOP_COUNT, PACKET_LOSS). checkType TRACEROUTE, top-level degraded/maxResponseTime (<=30000); port dropped for ICMP probes. Request shapes mirror the public API (verified against checkly-go-sdk types and the p5-parity captured payloads). Wires exports in constructs/index.ts, import codegen in check-codegen.ts (GRPC/SSL/TRACEROUTE -> *MonitorCodegen), and adds the three types to constants.CheckTypes. Tests: construct synthesize/validation/grouping specs, codegen specs (incl. ICMP port-strip and assertion builders), and a regression spec asserting the backend-style export snippets compile against checkly/constructs. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/constants.ts | 3 + .../constructs/__tests__/grpc-monitor.spec.ts | 142 ++++++++++ .../constructs/__tests__/ssl-monitor.spec.ts | 142 ++++++++++ .../__tests__/traceroute-monitor.spec.ts | 118 +++++++++ .../__tests__/uptime-monitor-codegen.spec.ts | 245 ++++++++++++++++++ .../uptime-monitor-export-snippets.spec.ts | 113 ++++++++ packages/cli/src/constructs/check-codegen.ts | 24 ++ .../src/constructs/grpc-assertion-codegen.ts | 31 +++ packages/cli/src/constructs/grpc-assertion.ts | 76 ++++++ .../src/constructs/grpc-monitor-codegen.ts | 50 ++++ packages/cli/src/constructs/grpc-monitor.ts | 93 +++++++ .../src/constructs/grpc-request-codegen.ts | 88 +++++++ packages/cli/src/constructs/grpc-request.ts | 157 +++++++++++ packages/cli/src/constructs/index.ts | 9 + .../src/constructs/ssl-assertion-codegen.ts | 36 +++ packages/cli/src/constructs/ssl-assertion.ts | 125 +++++++++ .../cli/src/constructs/ssl-monitor-codegen.ts | 40 +++ packages/cli/src/constructs/ssl-monitor.ts | 82 ++++++ .../cli/src/constructs/ssl-request-codegen.ts | 73 ++++++ packages/cli/src/constructs/ssl-request.ts | 185 +++++++++++++ .../traceroute-assertion-codegen.ts | 18 ++ .../src/constructs/traceroute-assertion.ts | 50 ++++ .../constructs/traceroute-monitor-codegen.ts | 50 ++++ .../cli/src/constructs/traceroute-monitor.ts | 93 +++++++ .../constructs/traceroute-request-codegen.ts | 56 ++++ .../cli/src/constructs/traceroute-request.ts | 95 +++++++ 26 files changed, 2194 insertions(+) create mode 100644 packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts create mode 100644 packages/cli/src/constructs/grpc-assertion-codegen.ts create mode 100644 packages/cli/src/constructs/grpc-assertion.ts create mode 100644 packages/cli/src/constructs/grpc-monitor-codegen.ts create mode 100644 packages/cli/src/constructs/grpc-monitor.ts create mode 100644 packages/cli/src/constructs/grpc-request-codegen.ts create mode 100644 packages/cli/src/constructs/grpc-request.ts create mode 100644 packages/cli/src/constructs/ssl-assertion-codegen.ts create mode 100644 packages/cli/src/constructs/ssl-assertion.ts create mode 100644 packages/cli/src/constructs/ssl-monitor-codegen.ts create mode 100644 packages/cli/src/constructs/ssl-monitor.ts create mode 100644 packages/cli/src/constructs/ssl-request-codegen.ts create mode 100644 packages/cli/src/constructs/ssl-request.ts create mode 100644 packages/cli/src/constructs/traceroute-assertion-codegen.ts create mode 100644 packages/cli/src/constructs/traceroute-assertion.ts create mode 100644 packages/cli/src/constructs/traceroute-monitor-codegen.ts create mode 100644 packages/cli/src/constructs/traceroute-monitor.ts create mode 100644 packages/cli/src/constructs/traceroute-request-codegen.ts create mode 100644 packages/cli/src/constructs/traceroute-request.ts diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index caf6384de..186fb5f6a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -8,6 +8,9 @@ export const CheckTypes = { ICMP: 'ICMP', DNS: 'DNS', URL: 'URL', + GRPC: 'GRPC', + SSL: 'SSL', + TRACEROUTE: 'TRACEROUTE', AGENTIC: 'AGENTIC', } as const diff --git a/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts new file mode 100644 index 000000000..906e580d7 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/grpc-monitor.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest' + +import { GrpcMonitor, GrpcAssertionBuilder, CheckGroup, GrpcRequest, Diagnostics } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' +import { Bundler } from '../../services/check-parser/bundler.js' + +const request: GrpcRequest = { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'BEHAVIOR', + method: '/grpc.health.v1.Health/Check', + }, +} + +function setupProject () { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +} + +describe('GrpcMonitor', () => { + it('should apply default check settings', () => { + setupProject() + Session.checkDefaults = { tags: ['default tags'] } + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['default tags'] }) + }) + + it('should overwrite default check settings with check-specific config', () => { + setupProject() + Session.checkDefaults = { tags: ['default tags'] } + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + tags: ['test check'], + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['test check'] }) + }) + + it('should synthesize the GRPC check type and request', () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + activated: false, + muted: true, + degradedResponseTime: 3000, + maxResponseTime: 10000, + request: { + url: 'grpc.example.com', + port: 50051, + ipFamily: 'IPv4', + skipSSL: false, + timeout: 60, + grpcConfig: { + mode: 'BEHAVIOR', + tls: true, + storeResponseBody: true, + serviceDefinition: 'REFLECTION', + method: '/grpc.health.v1.Health/Check', + }, + assertions: [ + GrpcAssertionBuilder.statusCode().equals(0), + GrpcAssertionBuilder.responseTime().lessThan(5000), + ], + }, + }) + + const payload = check.synthesize() + expect(payload).toMatchObject({ + checkType: 'GRPC', + activated: false, + muted: true, + degradedResponseTime: 3000, + maxResponseTime: 10000, + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'BEHAVIOR', + method: '/grpc.health.v1.Health/Check', + }, + assertions: [ + expect.objectContaining({ source: 'GRPC_STATUS_CODE', comparison: 'EQUALS', target: '0' }), + expect.objectContaining({ source: 'RESPONSE_TIME', comparison: 'LESS_THAN', target: '5000' }), + ], + }, + }) + }) + + it('should support setting groups with `group`', async () => { + setupProject() + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new GrpcMonitor('main-check', { + name: 'Main Check', + request, + group, + }) + const bundler = await Bundler.create({ cacheHash: 'foo' }) + const bundle = await check.bundle(bundler) + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + describe('validation', () => { + it('should error if degradedResponseTime is above 30000', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 30001, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('The value of "degradedResponseTime" must be 30000 or lower.'), + }), + ])) + }) + + it('should not error within limits', async () => { + setupProject() + const check = new GrpcMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 5000, + maxResponseTime: 10000, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts new file mode 100644 index 000000000..b3153013f --- /dev/null +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest' + +import { SslMonitor, SslAssertionBuilder, CheckGroup, SslRequest, Diagnostics } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' +import { Bundler } from '../../services/check-parser/bundler.js' + +const request: SslRequest = { + sslConfig: { + hostname: 'example.com', + port: 443, + }, +} + +function setupProject () { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +} + +describe('SslMonitor', () => { + it('should apply default check settings', () => { + setupProject() + Session.checkDefaults = { tags: ['default tags'] } + const check = new SslMonitor('test-check', { + name: 'Test Check', + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['default tags'] }) + }) + + it('should synthesize the SSL check type with nested config response times', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4', + skipChainValidation: false, + handshakeTimeoutMs: 10000, + alertDaysBeforeExpiry: 20, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + assertions: [ + SslAssertionBuilder.certExpiresInDays().greaterThan(20), + SslAssertionBuilder.chainTrusted().equals(true), + ], + }, + }) + + const payload = check.synthesize() as any + expect(payload).toMatchObject({ + checkType: 'SSL', + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + assertions: [ + expect.objectContaining({ source: 'CERT_EXPIRES_IN_DAYS', comparison: 'GREATER_THAN', target: '20' }), + expect.objectContaining({ source: 'CHAIN_TRUSTED', comparison: 'EQUALS', target: 'true' }), + ], + }, + }) + // SSL monitors carry no top-level response-time fields. + expect(payload.degradedResponseTime).toBeUndefined() + expect(payload.maxResponseTime).toBeUndefined() + }) + + it('should support setting groups with `group`', async () => { + setupProject() + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new SslMonitor('main-check', { + name: 'Main Check', + request, + group, + }) + const bundler = await Bundler.create({ cacheHash: 'foo' }) + const bundle = await check.bundle(bundler) + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + describe('validation', () => { + it('should error when clientCertificateMode is explicit but no certificate id is set', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + sslConfig: { + hostname: 'example.com', + clientCertificateMode: 'explicit', + }, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('A value for "sslClientCertificateId" is required'), + }), + ])) + }) + + it('should error when degradedResponseTimeMs exceeds maxResponseTimeMs', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + request: { + sslConfig: { + hostname: 'example.com', + degradedResponseTimeMs: 8000, + maxResponseTimeMs: 5000, + }, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('must be less than or equal to "maxResponseTimeMs"'), + }), + ])) + }) + + it('should not error for a valid config', async () => { + setupProject() + const check = new SslMonitor('test-check', { name: 'Test Check', request }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts new file mode 100644 index 000000000..d7ad93c2e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/traceroute-monitor.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest' + +import { TracerouteMonitor, TracerouteAssertionBuilder, CheckGroup, TracerouteRequest, Diagnostics } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' +import { Bundler } from '../../services/check-parser/bundler.js' + +const request: TracerouteRequest = { + url: 'example.com', +} + +function setupProject () { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +} + +describe('TracerouteMonitor', () => { + it('should apply default check settings', () => { + setupProject() + Session.checkDefaults = { tags: ['default tags'] } + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['default tags'] }) + }) + + it('should synthesize the TRACEROUTE check type and request', () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + ipFamily: 'IPv4', + maxHops: 30, + maxUnknownHops: 15, + ptrLookup: true, + timeout: 10, + assertions: [ + TracerouteAssertionBuilder.packetLoss().lessThan(10), + TracerouteAssertionBuilder.hopCount().lessThan(20), + ], + }, + }) + + const payload = check.synthesize() + expect(payload).toMatchObject({ + checkType: 'TRACEROUTE', + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + maxHops: 30, + maxUnknownHops: 15, + ptrLookup: true, + timeout: 10, + assertions: [ + expect.objectContaining({ source: 'PACKET_LOSS', comparison: 'LESS_THAN', target: '10' }), + expect.objectContaining({ source: 'HOP_COUNT', comparison: 'LESS_THAN', target: '20' }), + ], + }, + }) + }) + + it('should support setting groups with `group`', async () => { + setupProject() + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new TracerouteMonitor('main-check', { + name: 'Main Check', + request, + group, + }) + const bundler = await Bundler.create({ cacheHash: 'foo' }) + const bundle = await check.bundle(bundler) + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + describe('validation', () => { + it('should error if maxResponseTime is above 30000', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request, + maxResponseTime: 30001, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('The value of "maxResponseTime" must be 30000 or lower.'), + }), + ])) + }) + + it('should not error within limits', async () => { + setupProject() + const check = new TracerouteMonitor('test-check', { + name: 'Test Check', + request, + degradedResponseTime: 10000, + maxResponseTime: 20000, + }) + const diags = new Diagnostics() + await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + }) +}) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts new file mode 100644 index 000000000..74d1765db --- /dev/null +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -0,0 +1,245 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { GrpcMonitorCodegen, GrpcMonitorResource } from '../grpc-monitor-codegen.js' +import { SslMonitorCodegen, SslMonitorResource } from '../ssl-monitor-codegen.js' +import { TracerouteMonitorCodegen, TracerouteMonitorResource } from '../traceroute-monitor-codegen.js' +import { Codegen, Context } from '../internal/codegen/index.js' +import { Program } from '../../sourcegen/index.js' + +interface RenderEnv { + rootDirectory: string + cleanup: () => Promise +} + +async function createRenderEnv (): Promise { + const rootDirectory = await mkdtemp(path.join(tmpdir(), 'uptime-codegen-')) + return { + rootDirectory, + cleanup: () => rm(rootDirectory, { recursive: true, force: true }), + } +} + +async function renderResource ( + env: RenderEnv, + makeCodegen: (program: Program) => Codegen, + resource: T, +): Promise { + const program = new Program({ + rootDirectory: env.rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + + const codegen = makeCodegen(program) + const context = new Context() + + codegen.gencode(resource.id, resource, context) + + await program.realize() + + const [filePath] = program.paths + if (filePath === undefined) { + throw new Error('Codegen did not register any generated files') + } + + return readFile(filePath, 'utf8') +} + +describe('GrpcMonitorCodegen', () => { + let env: RenderEnv + beforeEach(async () => { + env = await createRenderEnv() + }) + afterEach(async () => { + await env.cleanup() + }) + + const resource = (overrides: Partial = {}): GrpcMonitorResource => ({ + id: 'grpc-check', + checkType: 'GRPC', + name: 'gRPC Monitor', + degradedResponseTime: 3000, + maxResponseTime: 10000, + request: { + url: 'grpc.example.com', + port: 50051, + ipFamily: 'IPv4', + skipSSL: false, + timeout: 60, + grpcConfig: { + mode: 'BEHAVIOR', + tls: true, + storeResponseBody: true, + serviceDefinition: 'REFLECTION', + method: '/grpc.health.v1.Health/Check', + }, + }, + ...overrides, + }) + + it('emits a compiling GrpcMonitor construct', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource()) + expect(source).toContain('GrpcMonitor') + expect(source).toContain('from \'checkly/constructs\'') + expect(source).toContain('new GrpcMonitor(\'grpc-check\'') + expect(source).toContain('url: \'grpc.example.com\'') + expect(source).toContain('port: 50051') + expect(source).toContain('grpcConfig: {') + expect(source).toContain('method: \'/grpc.health.v1.Health/Check\'') + expect(source).toContain('degradedResponseTime: 3000') + expect(source).toContain('maxResponseTime: 10000') + }) + + it('emits assertions through GrpcAssertionBuilder', async () => { + const source = await renderResource(env, p => new GrpcMonitorCodegen(p), resource({ + request: { + ...resource().request, + assertions: [ + { source: 'GRPC_STATUS_CODE', property: '', comparison: 'EQUALS', target: '0', regex: null }, + { source: 'RESPONSE_TIME', property: '', comparison: 'LESS_THAN', target: '5000', regex: null }, + ], + }, + })) + expect(source).toContain('GrpcAssertionBuilder') + expect(source).toContain('statusCode()') + expect(source).toContain('responseTime()') + }) + + it('describes the resource by name', () => { + const program = new Program({ + rootDirectory: env.rootDirectory, + constructFileSuffix: '.check', + specFileSuffix: '.spec', + language: 'typescript', + }) + expect(new GrpcMonitorCodegen(program).describe(resource())).toEqual('gRPC Monitor: gRPC Monitor') + }) +}) + +describe('SslMonitorCodegen', () => { + let env: RenderEnv + beforeEach(async () => { + env = await createRenderEnv() + }) + afterEach(async () => { + await env.cleanup() + }) + + const resource = (overrides: Partial = {}): SslMonitorResource => ({ + id: 'ssl-check', + checkType: 'SSL', + name: 'SSL Monitor', + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4', + skipChainValidation: false, + handshakeTimeoutMs: 10000, + alertDaysBeforeExpiry: 20, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + }, + ...overrides, + }) + + it('emits a compiling SslMonitor construct', async () => { + const source = await renderResource(env, p => new SslMonitorCodegen(p), resource()) + expect(source).toContain('SslMonitor') + expect(source).toContain('from \'checkly/constructs\'') + expect(source).toContain('new SslMonitor(\'ssl-check\'') + expect(source).toContain('sslConfig: {') + expect(source).toContain('hostname: \'example.com\'') + expect(source).toContain('handshakeTimeoutMs: 10000') + expect(source).toContain('alertDaysBeforeExpiry: 20') + expect(source).toContain('degradedResponseTimeMs: 3000') + expect(source).toContain('maxResponseTimeMs: 10000') + // No top-level response-time fields for SSL. + expect(source).not.toContain('degradedResponseTime:') + expect(source).not.toContain('maxResponseTime:') + }) + + it('emits assertions through SslAssertionBuilder', async () => { + const source = await renderResource(env, p => new SslMonitorCodegen(p), resource({ + request: { + sslConfig: { hostname: 'example.com' }, + assertions: [ + { source: 'CERT_EXPIRES_IN_DAYS', property: '', comparison: 'GREATER_THAN', target: '20', regex: null }, + { source: 'CHAIN_TRUSTED', property: '', comparison: 'EQUALS', target: 'true', regex: null }, + ], + }, + })) + expect(source).toContain('SslAssertionBuilder') + expect(source).toContain('certExpiresInDays()') + expect(source).toContain('chainTrusted()') + }) +}) + +describe('TracerouteMonitorCodegen', () => { + let env: RenderEnv + beforeEach(async () => { + env = await createRenderEnv() + }) + afterEach(async () => { + await env.cleanup() + }) + + const resource = (overrides: Partial = {}): TracerouteMonitorResource => ({ + id: 'traceroute-check', + checkType: 'TRACEROUTE', + name: 'Traceroute Monitor', + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + ipFamily: 'IPv4', + maxHops: 30, + maxUnknownHops: 15, + ptrLookup: true, + timeout: 10, + }, + ...overrides, + }) + + it('emits a compiling TracerouteMonitor construct', async () => { + const source = await renderResource(env, p => new TracerouteMonitorCodegen(p), resource()) + expect(source).toContain('TracerouteMonitor') + expect(source).toContain('from \'checkly/constructs\'') + expect(source).toContain('new TracerouteMonitor(\'traceroute-check\'') + expect(source).toContain('url: \'example.com\'') + expect(source).toContain('maxHops: 30') + expect(source).toContain('maxUnknownHops: 15') + expect(source).toContain('ptrLookup: true') + expect(source).toContain('degradedResponseTime: 10000') + expect(source).toContain('maxResponseTime: 20000') + }) + + it('drops port for ICMP probes', async () => { + const source = await renderResource(env, p => new TracerouteMonitorCodegen(p), resource({ + request: { url: 'example.com', protocol: 'ICMP', port: 443 }, + })) + expect(source).toContain('protocol: \'ICMP\'') + expect(source).not.toContain('port: 443') + }) + + it('emits assertions through TracerouteAssertionBuilder', async () => { + const source = await renderResource(env, p => new TracerouteMonitorCodegen(p), resource({ + request: { + url: 'example.com', + assertions: [ + { source: 'PACKET_LOSS', property: '', comparison: 'LESS_THAN', target: '10', regex: null }, + ], + }, + })) + expect(source).toContain('TracerouteAssertionBuilder') + expect(source).toContain('packetLoss()') + }) +}) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts new file mode 100644 index 000000000..04857854e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +// This spec locks in that the backend's CLI export templates — which emit +// `import { GrpcMonitor, SslMonitor, TracerouteMonitor } from 'checkly/constructs'` +// and the request shapes captured in p5-parity-work — compile and type-check +// against the CLI package's public construct API, and synthesize the wire +// payload the public API expects. +import { GrpcMonitor, SslMonitor, TracerouteMonitor } from '../index.js' +import { Project } from '../project.js' +import { Session } from '../session.js' + +beforeEach(() => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) +}) + +describe('backend-style export snippets', () => { + it('GrpcMonitor snippet compiles and synthesizes a GRPC payload', () => { + const monitor = new GrpcMonitor('p5-parity-grpc-check', { + name: 'p5-parity-grpc', + activated: true, + muted: false, + frequency: 1, + locations: ['us-east-1'], + tags: ['p5-parity'], + degradedResponseTime: 5000, + maxResponseTime: 10000, + request: { + url: 'grpc.example.com', + port: 50051, + ipFamily: 'IPv4', + skipSSL: false, + timeout: 60, + grpcConfig: { + mode: 'BEHAVIOR', + tls: true, + storeResponseBody: true, + serviceDefinition: 'REFLECTION', + method: '/grpc.health.v1.Health/Check', + }, + }, + }) + + expect(monitor.synthesize()).toMatchObject({ + checkType: 'GRPC', + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { mode: 'BEHAVIOR', method: '/grpc.health.v1.Health/Check' }, + }, + }) + }) + + it('SslMonitor snippet compiles and synthesizes an SSL payload', () => { + const monitor = new SslMonitor('p5-parity-ssl-check', { + name: 'p5-parity-ssl', + activated: true, + muted: false, + frequency: 1, + locations: ['us-east-1'], + tags: ['p5-parity'], + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4', + skipChainValidation: false, + handshakeTimeoutMs: 10000, + alertDaysBeforeExpiry: 20, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + }, + }) + + expect(monitor.synthesize()).toMatchObject({ + checkType: 'SSL', + request: { + sslConfig: { hostname: 'example.com', port: 443, maxResponseTimeMs: 10000 }, + }, + }) + }) + + it('TracerouteMonitor snippet compiles and synthesizes a TRACEROUTE payload', () => { + const monitor = new TracerouteMonitor('p5-parity-traceroute-check', { + name: 'p5-parity-traceroute', + activated: true, + muted: false, + frequency: 1, + locations: ['us-east-1'], + tags: ['p5-parity'], + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + ipFamily: 'IPv4', + maxHops: 30, + maxUnknownHops: 15, + ptrLookup: true, + timeout: 10, + }, + }) + + expect(monitor.synthesize()).toMatchObject({ + checkType: 'TRACEROUTE', + request: { url: 'example.com', protocol: 'TCP', port: 443, maxHops: 30 }, + }) + }) +}) diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index a31c66960..2204b2857 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -17,6 +17,9 @@ import { valueForPrivateLocationFromId } from './private-location-codegen.js' import { valueForAlertChannelFromId } from './alert-channel-codegen.js' import { DnsMonitorCodegen, DnsMonitorResource } from './dns-monitor-codegen.js' import { IcmpMonitorCodegen, IcmpMonitorResource } from './icmp-monitor-codegen.js' +import { GrpcMonitorCodegen, GrpcMonitorResource } from './grpc-monitor-codegen.js' +import { SslMonitorCodegen, SslMonitorResource } from './ssl-monitor-codegen.js' +import { TracerouteMonitorCodegen, TracerouteMonitorResource } from './traceroute-monitor-codegen.js' export interface CheckResource { id: string @@ -248,6 +251,9 @@ export class CheckCodegen extends Codegen { urlMonitorCodegen: UrlMonitorCodegen dnsMonitorCodegen: DnsMonitorCodegen icmpMonitorCodegen: IcmpMonitorCodegen + grpcMonitorCodegen: GrpcMonitorCodegen + sslMonitorCodegen: SslMonitorCodegen + tracerouteMonitorCodegen: TracerouteMonitorCodegen constructor (program: Program) { super(program) @@ -261,6 +267,9 @@ export class CheckCodegen extends Codegen { this.urlMonitorCodegen = new UrlMonitorCodegen(program) this.dnsMonitorCodegen = new DnsMonitorCodegen(program) this.icmpMonitorCodegen = new IcmpMonitorCodegen(program) + this.grpcMonitorCodegen = new GrpcMonitorCodegen(program) + this.sslMonitorCodegen = new SslMonitorCodegen(program) + this.tracerouteMonitorCodegen = new TracerouteMonitorCodegen(program) } describe (resource: CheckResource): string { @@ -285,6 +294,12 @@ export class CheckCodegen extends Codegen { return this.dnsMonitorCodegen.describe(resource as DnsMonitorResource) case 'ICMP': return this.icmpMonitorCodegen.describe(resource as IcmpMonitorResource) + case 'GRPC': + return this.grpcMonitorCodegen.describe(resource as GrpcMonitorResource) + case 'SSL': + return this.sslMonitorCodegen.describe(resource as SslMonitorResource) + case 'TRACEROUTE': + return this.tracerouteMonitorCodegen.describe(resource as TracerouteMonitorResource) default: throw new Error(`Unable to describe unsupported check type '${checkType}'.`) } @@ -321,6 +336,15 @@ export class CheckCodegen extends Codegen { case 'ICMP': this.icmpMonitorCodegen.gencode(logicalId, resource as IcmpMonitorResource, context) return + case 'GRPC': + this.grpcMonitorCodegen.gencode(logicalId, resource as GrpcMonitorResource, context) + return + case 'SSL': + this.sslMonitorCodegen.gencode(logicalId, resource as SslMonitorResource, context) + return + case 'TRACEROUTE': + this.tracerouteMonitorCodegen.gencode(logicalId, resource as TracerouteMonitorResource, context) + return default: throw new Error(`Unable to generate code for unsupported check type '${checkType}'.`) } diff --git a/packages/cli/src/constructs/grpc-assertion-codegen.ts b/packages/cli/src/constructs/grpc-assertion-codegen.ts new file mode 100644 index 000000000..cff02396a --- /dev/null +++ b/packages/cli/src/constructs/grpc-assertion-codegen.ts @@ -0,0 +1,31 @@ +import { GeneratedFile, Value } from '../sourcegen/index.js' +import { valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen.js' +import { GrpcAssertion } from './grpc-assertion.js' + +export function valueForGrpcAssertion (genfile: GeneratedFile, assertion: GrpcAssertion): Value { + genfile.namedImport('GrpcAssertionBuilder', 'checkly/constructs') + + switch (assertion.source) { + case 'RESPONSE_TIME': + return valueForNumericAssertion('GrpcAssertionBuilder', 'responseTime', assertion) + case 'GRPC_STATUS_CODE': + return valueForNumericAssertion('GrpcAssertionBuilder', 'statusCode', assertion) + case 'GRPC_HEALTHCHECK_STATUS': + return valueForGeneralAssertion('GrpcAssertionBuilder', 'healthCheckStatus', assertion, { + hasProperty: false, + hasRegex: false, + }) + case 'GRPC_RESPONSE': + return valueForGeneralAssertion('GrpcAssertionBuilder', 'responseMessage', assertion, { + hasProperty: true, + hasRegex: false, + }) + case 'GRPC_METADATA': + return valueForGeneralAssertion('GrpcAssertionBuilder', 'responseMetadata', assertion, { + hasProperty: true, + hasRegex: false, + }) + default: + throw new Error(`Unsupported gRPC assertion source ${assertion.source}`) + } +} diff --git a/packages/cli/src/constructs/grpc-assertion.ts b/packages/cli/src/constructs/grpc-assertion.ts new file mode 100644 index 000000000..a44bec7b9 --- /dev/null +++ b/packages/cli/src/constructs/grpc-assertion.ts @@ -0,0 +1,76 @@ +import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' + +type GrpcAssertionSource = + | 'RESPONSE_TIME' + | 'GRPC_STATUS_CODE' + | 'GRPC_HEALTHCHECK_STATUS' + | 'GRPC_RESPONSE' + | 'GRPC_METADATA' + +export type GrpcAssertion = CoreAssertion + +/** + * Builder class for creating gRPC monitor assertions. + * Provides methods to create assertions for gRPC call responses. + * + * @example + * ```typescript + * // Response time assertions + * GrpcAssertionBuilder.responseTime().lessThan(1000) + * + * // gRPC status code assertions (e.g. 0 = OK) + * GrpcAssertionBuilder.statusCode().equals(0) + * + * // Health-check status assertions (HEALTH mode) + * GrpcAssertionBuilder.healthCheckStatus().equals('SERVING') + * + * // Response message assertions (BEHAVIOR mode) + * GrpcAssertionBuilder.responseMessage('$.status').equals('ok') + * + * // Response metadata (header) assertions + * GrpcAssertionBuilder.responseMetadata('content-type').contains('grpc') + * ``` + */ +export class GrpcAssertionBuilder { + /** + * Creates an assertion builder for gRPC response time. + * @returns A numeric assertion builder for response time in milliseconds. + */ + static responseTime () { + return new NumericAssertionBuilder('RESPONSE_TIME') + } + + /** + * Creates an assertion builder for the gRPC status code. + * @returns A numeric assertion builder for the gRPC status code. + */ + static statusCode () { + return new NumericAssertionBuilder('GRPC_STATUS_CODE') + } + + /** + * Creates an assertion builder for the gRPC health-check status (HEALTH mode). + * @returns A general assertion builder for the health-check status. + */ + static healthCheckStatus () { + return new GeneralAssertionBuilder('GRPC_HEALTHCHECK_STATUS') + } + + /** + * Creates an assertion builder for the gRPC response message (BEHAVIOR mode). + * @param property Optional JSON path to a specific property (e.g., '$.status'). + * @returns A general assertion builder for the response message. + */ + static responseMessage (property?: string) { + return new GeneralAssertionBuilder('GRPC_RESPONSE', property) + } + + /** + * Creates an assertion builder for gRPC response metadata (headers). + * @param property Optional metadata key to assert against. + * @returns A general assertion builder for the response metadata. + */ + static responseMetadata (property?: string) { + return new GeneralAssertionBuilder('GRPC_METADATA', property) + } +} diff --git a/packages/cli/src/constructs/grpc-monitor-codegen.ts b/packages/cli/src/constructs/grpc-monitor-codegen.ts new file mode 100644 index 000000000..ab717bb57 --- /dev/null +++ b/packages/cli/src/constructs/grpc-monitor-codegen.ts @@ -0,0 +1,50 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { expr, ident } from '../sourcegen/index.js' +import { buildMonitorProps, MonitorResource } from './monitor-codegen.js' +import { GrpcRequest } from './grpc-request.js' +import { valueForGrpcRequest } from './grpc-request-codegen.js' + +export interface GrpcMonitorResource extends MonitorResource { + checkType: 'GRPC' + request: GrpcRequest + degradedResponseTime?: number + maxResponseTime?: number +} + +const construct = 'GrpcMonitor' + +export class GrpcMonitorCodegen extends Codegen { + describe (resource: GrpcMonitorResource): string { + return `gRPC Monitor: ${resource.name}` + } + + gencode (logicalId: string, resource: GrpcMonitorResource, context: Context): void { + const filePath = context.filePath('resources/grpc-monitors', resource.name, { + tags: resource.tags, + unique: true, + }) + + const file = this.program.generatedConstructFile(filePath.fullPath) + + file.namedImport(construct, 'checkly/constructs') + + file.section(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + if (resource.degradedResponseTime !== undefined) { + builder.number('degradedResponseTime', resource.degradedResponseTime) + } + + if (resource.maxResponseTime !== undefined) { + builder.number('maxResponseTime', resource.maxResponseTime) + } + + buildMonitorProps(this.program, file, builder, resource, context) + + builder.value('request', valueForGrpcRequest(this.program, file, context, resource.request)) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/grpc-monitor.ts b/packages/cli/src/constructs/grpc-monitor.ts new file mode 100644 index 000000000..e8be032e7 --- /dev/null +++ b/packages/cli/src/constructs/grpc-monitor.ts @@ -0,0 +1,93 @@ +import { Monitor, MonitorProps } from './monitor.js' +import { Session } from './session.js' +import { Diagnostics } from './diagnostics.js' +import { validateResponseTimes } from './internal/common-diagnostics.js' +import { GrpcRequest } from './grpc-request.js' + +export interface GrpcMonitorProps extends MonitorProps { + /** + * Determines the request that the monitor is going to run. + */ + request: GrpcRequest + + /** + * The response time in milliseconds where the monitor should be considered + * degraded. + * + * @defaultValue 4000 + * @minimum 0 + * @maximum 30000 + * @example + * ```typescript + * degradedResponseTime: 3000 // Alert when the gRPC call takes longer than 3 seconds + * ``` + */ + degradedResponseTime?: number + + /** + * The response time in milliseconds where the monitor should be considered + * failing. + * + * @defaultValue 5000 + * @minimum 0 + * @maximum 30000 + * @example + * ```typescript + * maxResponseTime: 10000 // Fail if the gRPC call takes longer than 10 seconds + * ``` + */ + maxResponseTime?: number +} + +/** + * Creates a gRPC Monitor + */ +export class GrpcMonitor extends Monitor { + request: GrpcRequest + degradedResponseTime?: number + maxResponseTime?: number + + /** + * Constructs the gRPC Monitor instance + * + * @param logicalId unique project-scoped resource name identification + * @param props configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/grpc-monitor/ Read more in the docs} + */ + + constructor (logicalId: string, props: GrpcMonitorProps) { + super(logicalId, props) + + this.request = props.request + this.degradedResponseTime = props.degradedResponseTime + this.maxResponseTime = props.maxResponseTime + + Session.registerConstruct(this) + this.addSubscriptions() + this.addPrivateLocationCheckAssignments() + } + + describe (): string { + return `GrpcMonitor:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + await validateResponseTimes(diagnostics, this, { + degradedResponseTime: 30_000, + maxResponseTime: 30_000, + }) + } + + synthesize () { + return { + ...super.synthesize(), + checkType: 'GRPC', + request: this.request, + degradedResponseTime: this.degradedResponseTime, + maxResponseTime: this.maxResponseTime, + } + } +} diff --git a/packages/cli/src/constructs/grpc-request-codegen.ts b/packages/cli/src/constructs/grpc-request-codegen.ts new file mode 100644 index 000000000..ec7a87b06 --- /dev/null +++ b/packages/cli/src/constructs/grpc-request-codegen.ts @@ -0,0 +1,88 @@ +import { GeneratedFile, object, Program, Value } from '../sourcegen/index.js' +import { valueForGrpcAssertion } from './grpc-assertion-codegen.js' +import { GrpcRequest } from './grpc-request.js' +import { Context } from './internal/codegen/index.js' + +export function valueForGrpcRequest ( + program: Program, + genfile: GeneratedFile, + context: Context, + request: GrpcRequest, +): Value { + return object(builder => { + builder.string('url', request.url) + builder.number('port', request.port) + + if (request.ipFamily && request.ipFamily !== 'IPv4') { + builder.string('ipFamily', request.ipFamily) + } + + if (request.skipSSL) { + builder.boolean('skipSSL', request.skipSSL) + } + + if (request.timeout !== undefined) { + builder.number('timeout', request.timeout) + } + + const config = request.grpcConfig + builder.object('grpcConfig', builder => { + if (config.mode) { + builder.string('mode', config.mode) + } + + if (config.tls !== undefined) { + builder.boolean('tls', config.tls) + } + + if (config.storeResponseBody !== undefined) { + builder.boolean('storeResponseBody', config.storeResponseBody) + } + + if (config.serviceDefinition) { + builder.string('serviceDefinition', config.serviceDefinition) + } + + if (config.method) { + builder.string('method', config.method) + } + + if (config.protoContent) { + builder.string('protoContent', config.protoContent) + } + + if (config.message) { + builder.string('message', config.message) + } + + if (config.service) { + builder.string('service', config.service) + } + + if (config.metadata) { + const metadata = config.metadata + if (metadata.length > 0) { + builder.array('metadata', builder => { + for (const entry of metadata) { + builder.object(builder => { + builder.string('key', entry.key) + builder.string('value', entry.value) + }) + } + }) + } + } + }) + + if (request.assertions) { + const assertions = request.assertions + if (assertions.length > 0) { + builder.array('assertions', builder => { + for (const assertion of assertions) { + builder.value(valueForGrpcAssertion(genfile, assertion)) + } + }) + } + } + }) +} diff --git a/packages/cli/src/constructs/grpc-request.ts b/packages/cli/src/constructs/grpc-request.ts new file mode 100644 index 000000000..77557fb6d --- /dev/null +++ b/packages/cli/src/constructs/grpc-request.ts @@ -0,0 +1,157 @@ +import { GrpcAssertion } from './grpc-assertion.js' +import { IPFamily } from './ip.js' + +/** + * The gRPC monitoring mode. + * + * - `BEHAVIOR` invokes a unary method (requires `method`). + * - `HEALTH` queries the standard gRPC health-check service (allows `service`). + */ +export type GrpcMode = 'BEHAVIOR' | 'HEALTH' + +/** + * How the service definition is resolved in `BEHAVIOR` mode. + * + * - `REFLECTION` uses server reflection. + * - `PROTO_FILE` uses the inline `protoContent`. + */ +export type GrpcServiceDefinition = 'REFLECTION' | 'PROTO_FILE' + +/** + * A single gRPC metadata (request header) key/value pair. + */ +export interface GrpcMetadata { + /** + * The gRPC metadata (header) key. + */ + key: string + + /** + * The gRPC metadata (header) value. + */ + value: string +} + +/** + * gRPC-specific configuration nested inside a gRPC monitor's request. + */ +export interface GrpcConfig { + /** + * The gRPC monitoring mode. `BEHAVIOR` invokes a unary method (requires + * `method`); `HEALTH` queries the standard gRPC health-check service (allows + * `service`). + * + * @default "BEHAVIOR" + */ + mode?: GrpcMode + + /** + * Whether to use a TLS-encrypted connection to the gRPC server. + * + * @default true + */ + tls?: boolean + + /** + * Whether to store the gRPC response body with the check result. + * + * @default true + */ + storeResponseBody?: boolean + + /** + * gRPC metadata (request headers) sent with the call. + */ + metadata?: Array + + /** + * How the service definition is resolved in `BEHAVIOR` mode: `REFLECTION` + * uses server reflection; `PROTO_FILE` uses the inline `protoContent`. + * Forbidden in `HEALTH` mode. + * + * @default "REFLECTION" + */ + serviceDefinition?: GrpcServiceDefinition + + /** + * The fully-qualified gRPC method to invoke in `BEHAVIOR` mode (e.g. + * `package.Service/Method`). Required in `BEHAVIOR` mode; forbidden in + * `HEALTH` mode. + * + * @example "/grpc.health.v1.Health/Check" + */ + method?: string + + /** + * The inline `.proto` file source used when `serviceDefinition` is + * `PROTO_FILE` in `BEHAVIOR` mode. + */ + protoContent?: string + + /** + * The JSON request message sent as the gRPC call payload in `BEHAVIOR` mode. + */ + message?: string + + /** + * The service name to query in `HEALTH` mode. An empty value queries overall + * server health. Forbidden in `BEHAVIOR` mode. + */ + service?: string +} + +/** + * Configuration for gRPC requests. + * Defines the connection parameters and validation rules. + */ +export interface GrpcRequest { + /** + * The host to connect to. Do not include a scheme or a port in this value. + * + * @example "grpc.example.com" + */ + url: string + + /** + * The port number to connect to. + * + * @minimum 1 + * @maximum 65535 + * @example 50051 + */ + port: number + + /** + * The IP family to use when executing the gRPC check. + * + * @default "IPv4" + */ + ipFamily?: IPFamily + + /** + * Whether to skip SSL certificate validation when `tls` is enabled. + * + * @default false + */ + skipSSL?: boolean + + /** + * The number of seconds to wait for the gRPC call to complete before timing + * out. + * + * @minimum 1 + * @maximum 180 + * @default 60 + */ + timeout?: number + + /** + * The gRPC-specific configuration for the call. + */ + grpcConfig: GrpcConfig + + /** + * Assertions to validate the gRPC response. + */ + assertions?: Array +} diff --git a/packages/cli/src/constructs/index.ts b/packages/cli/src/constructs/index.ts index 1ad008fd8..f3d0cb2a8 100644 --- a/packages/cli/src/constructs/index.ts +++ b/packages/cli/src/constructs/index.ts @@ -51,4 +51,13 @@ export * from './dns-request.js' export * from './icmp-monitor.js' export * from './icmp-assertion.js' export * from './icmp-request.js' +export * from './grpc-monitor.js' +export * from './grpc-assertion.js' +export * from './grpc-request.js' +export * from './ssl-monitor.js' +export * from './ssl-assertion.js' +export * from './ssl-request.js' +export * from './traceroute-monitor.js' +export * from './traceroute-assertion.js' +export * from './traceroute-request.js' export * from './agentic-check.js' diff --git a/packages/cli/src/constructs/ssl-assertion-codegen.ts b/packages/cli/src/constructs/ssl-assertion-codegen.ts new file mode 100644 index 000000000..bce14c81c --- /dev/null +++ b/packages/cli/src/constructs/ssl-assertion-codegen.ts @@ -0,0 +1,36 @@ +import { GeneratedFile, Value } from '../sourcegen/index.js' +import { valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen.js' +import { SslAssertion } from './ssl-assertion.js' + +const generalNoArgs = { hasProperty: false, hasRegex: false } + +export function valueForSslAssertion (genfile: GeneratedFile, assertion: SslAssertion): Value { + genfile.namedImport('SslAssertionBuilder', 'checkly/constructs') + + switch (assertion.source) { + case 'CERT_EXPIRES_IN_DAYS': + return valueForNumericAssertion('SslAssertionBuilder', 'certExpiresInDays', assertion) + case 'KEY_SIZE_BITS': + return valueForNumericAssertion('SslAssertionBuilder', 'keySizeBits', assertion) + case 'CERT_NOT_EXPIRED': + return valueForGeneralAssertion('SslAssertionBuilder', 'certNotExpired', assertion, generalNoArgs) + case 'HOSTNAME_VERIFIED': + return valueForGeneralAssertion('SslAssertionBuilder', 'hostnameVerified', assertion, generalNoArgs) + case 'CHAIN_TRUSTED': + return valueForGeneralAssertion('SslAssertionBuilder', 'chainTrusted', assertion, generalNoArgs) + case 'TLS_VERSION': + return valueForGeneralAssertion('SslAssertionBuilder', 'tlsVersion', assertion, generalNoArgs) + case 'CIPHER_SUITE': + return valueForGeneralAssertion('SslAssertionBuilder', 'cipherSuite', assertion, generalNoArgs) + case 'ISSUER_CN': + return valueForGeneralAssertion('SslAssertionBuilder', 'issuerCn', assertion, generalNoArgs) + case 'CERT_FINGERPRINT_SHA256': + return valueForGeneralAssertion('SslAssertionBuilder', 'certFingerprintSha256', assertion, generalNoArgs) + case 'ISSUER_FINGERPRINT_SHA256': + return valueForGeneralAssertion('SslAssertionBuilder', 'issuerFingerprintSha256', assertion, generalNoArgs) + case 'SIGNATURE_ALGORITHM': + return valueForGeneralAssertion('SslAssertionBuilder', 'signatureAlgorithm', assertion, generalNoArgs) + default: + throw new Error(`Unsupported SSL assertion source ${assertion.source}`) + } +} diff --git a/packages/cli/src/constructs/ssl-assertion.ts b/packages/cli/src/constructs/ssl-assertion.ts new file mode 100644 index 000000000..d38ea5704 --- /dev/null +++ b/packages/cli/src/constructs/ssl-assertion.ts @@ -0,0 +1,125 @@ +import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion.js' + +type SslAssertionSource = + | 'CERT_EXPIRES_IN_DAYS' + | 'CERT_NOT_EXPIRED' + | 'HOSTNAME_VERIFIED' + | 'CHAIN_TRUSTED' + | 'TLS_VERSION' + | 'CIPHER_SUITE' + | 'ISSUER_CN' + | 'CERT_FINGERPRINT_SHA256' + | 'ISSUER_FINGERPRINT_SHA256' + | 'KEY_SIZE_BITS' + | 'SIGNATURE_ALGORITHM' + +export type SslAssertion = CoreAssertion + +/** + * Builder class for creating SSL monitor assertions. + * Provides methods to create assertions for TLS certificates. + * + * @example + * ```typescript + * // Alert when the certificate is within 30 days of expiry + * SslAssertionBuilder.certExpiresInDays().greaterThan(30) + * + * // Require a trusted chain and a verified hostname + * SslAssertionBuilder.chainTrusted().equals(true) + * SslAssertionBuilder.hostnameVerified().equals(true) + * + * // Enforce a minimum TLS version and key size + * SslAssertionBuilder.tlsVersion().equals('TLS1.3') + * SslAssertionBuilder.keySizeBits().greaterThan(2048) + * ``` + */ +export class SslAssertionBuilder { + /** + * Creates an assertion builder for the number of days until the certificate + * expires. + * @returns A numeric assertion builder for days until expiry. + */ + static certExpiresInDays () { + return new NumericAssertionBuilder('CERT_EXPIRES_IN_DAYS') + } + + /** + * Creates an assertion builder for the certificate key size in bits. + * @returns A numeric assertion builder for the key size. + */ + static keySizeBits () { + return new NumericAssertionBuilder('KEY_SIZE_BITS') + } + + /** + * Creates an assertion builder for whether the certificate is not expired. + * @returns A general assertion builder for the expiry status. + */ + static certNotExpired () { + return new GeneralAssertionBuilder('CERT_NOT_EXPIRED') + } + + /** + * Creates an assertion builder for whether the hostname is verified. + * @returns A general assertion builder for the hostname verification status. + */ + static hostnameVerified () { + return new GeneralAssertionBuilder('HOSTNAME_VERIFIED') + } + + /** + * Creates an assertion builder for whether the certificate chain is trusted. + * @returns A general assertion builder for the chain trust status. + */ + static chainTrusted () { + return new GeneralAssertionBuilder('CHAIN_TRUSTED') + } + + /** + * Creates an assertion builder for the negotiated TLS version. + * @returns A general assertion builder for the TLS version. + */ + static tlsVersion () { + return new GeneralAssertionBuilder('TLS_VERSION') + } + + /** + * Creates an assertion builder for the negotiated cipher suite. + * @returns A general assertion builder for the cipher suite. + */ + static cipherSuite () { + return new GeneralAssertionBuilder('CIPHER_SUITE') + } + + /** + * Creates an assertion builder for the certificate issuer common name. + * @returns A general assertion builder for the issuer CN. + */ + static issuerCn () { + return new GeneralAssertionBuilder('ISSUER_CN') + } + + /** + * Creates an assertion builder for the certificate SHA-256 fingerprint. + * @returns A general assertion builder for the certificate fingerprint. + */ + static certFingerprintSha256 () { + return new GeneralAssertionBuilder('CERT_FINGERPRINT_SHA256') + } + + /** + * Creates an assertion builder for the issuer SHA-256 fingerprint. + * @returns A general assertion builder for the issuer fingerprint. + */ + static issuerFingerprintSha256 () { + return new GeneralAssertionBuilder('ISSUER_FINGERPRINT_SHA256') + } + + /** + * Creates an assertion builder for the certificate signature algorithm. + * @returns A general assertion builder for the signature algorithm. + */ + static signatureAlgorithm () { + return new GeneralAssertionBuilder('SIGNATURE_ALGORITHM') + } +} diff --git a/packages/cli/src/constructs/ssl-monitor-codegen.ts b/packages/cli/src/constructs/ssl-monitor-codegen.ts new file mode 100644 index 000000000..cd9e39989 --- /dev/null +++ b/packages/cli/src/constructs/ssl-monitor-codegen.ts @@ -0,0 +1,40 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { expr, ident } from '../sourcegen/index.js' +import { buildMonitorProps, MonitorResource } from './monitor-codegen.js' +import { SslRequest } from './ssl-request.js' +import { valueForSslRequest } from './ssl-request-codegen.js' + +export interface SslMonitorResource extends MonitorResource { + checkType: 'SSL' + request: SslRequest +} + +const construct = 'SslMonitor' + +export class SslMonitorCodegen extends Codegen { + describe (resource: SslMonitorResource): string { + return `SSL Monitor: ${resource.name}` + } + + gencode (logicalId: string, resource: SslMonitorResource, context: Context): void { + const filePath = context.filePath('resources/ssl-monitors', resource.name, { + tags: resource.tags, + unique: true, + }) + + const file = this.program.generatedConstructFile(filePath.fullPath) + + file.namedImport(construct, 'checkly/constructs') + + file.section(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + buildMonitorProps(this.program, file, builder, resource, context) + + builder.value('request', valueForSslRequest(this.program, file, context, resource.request)) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts new file mode 100644 index 000000000..7321323cb --- /dev/null +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -0,0 +1,82 @@ +import { Monitor, MonitorProps } from './monitor.js' +import { Session } from './session.js' +import { Diagnostics } from './diagnostics.js' +import { SslRequest } from './ssl-request.js' +import { InvalidPropertyValueDiagnostic, RequiredPropertyDiagnostic } from './construct-diagnostics.js' + +export interface SslMonitorProps extends MonitorProps { + /** + * Determines the request that the monitor is going to run. + * + * Unlike most monitors, the response-time limits for an SSL monitor live + * inside the request as `sslConfig.degradedResponseTimeMs` and + * `sslConfig.maxResponseTimeMs`. + */ + request: SslRequest +} + +/** + * Creates an SSL Monitor + */ +export class SslMonitor extends Monitor { + request: SslRequest + + /** + * Constructs the SSL Monitor instance + * + * @param logicalId unique project-scoped resource name identification + * @param props configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/ssl-monitor/ Read more in the docs} + */ + + constructor (logicalId: string, props: SslMonitorProps) { + super(logicalId, props) + + this.request = props.request + + Session.registerConstruct(this) + this.addSubscriptions() + this.addPrivateLocationCheckAssignments() + } + + describe (): string { + return `SslMonitor:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + const config = this.request.sslConfig + + if (this.request.sslClientCertificateId === undefined && config?.clientCertificateMode === 'explicit') { + diagnostics.add(new RequiredPropertyDiagnostic( + 'sslClientCertificateId', + new Error( + `A value for "sslClientCertificateId" is required when "clientCertificateMode" is "explicit".`, + ), + )) + } + + if ( + config?.degradedResponseTimeMs !== undefined + && config?.maxResponseTimeMs !== undefined + && config.degradedResponseTimeMs > config.maxResponseTimeMs + ) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'degradedResponseTimeMs', + new Error( + `The value of "degradedResponseTimeMs" must be less than or equal to "maxResponseTimeMs".`, + ), + )) + } + } + + synthesize () { + return { + ...super.synthesize(), + checkType: 'SSL', + request: this.request, + } + } +} diff --git a/packages/cli/src/constructs/ssl-request-codegen.ts b/packages/cli/src/constructs/ssl-request-codegen.ts new file mode 100644 index 000000000..4ca3e07b1 --- /dev/null +++ b/packages/cli/src/constructs/ssl-request-codegen.ts @@ -0,0 +1,73 @@ +import { GeneratedFile, object, Program, unknown, Value } from '../sourcegen/index.js' +import { valueForSslAssertion } from './ssl-assertion-codegen.js' +import { SslRequest } from './ssl-request.js' +import { Context } from './internal/codegen/index.js' + +export function valueForSslRequest ( + program: Program, + genfile: GeneratedFile, + context: Context, + request: SslRequest, +): Value { + return object(builder => { + const config = request.sslConfig + builder.object('sslConfig', builder => { + builder.string('hostname', config.hostname) + + if (config.port !== undefined && config.port !== 443) { + builder.number('port', config.port) + } + + if (config.serverName) { + builder.string('serverName', config.serverName) + } + + if (config.ipFamily && config.ipFamily !== 'IPv4') { + builder.string('ipFamily', config.ipFamily) + } + + if (config.skipChainValidation) { + builder.boolean('skipChainValidation', config.skipChainValidation) + } + + if (config.handshakeTimeoutMs !== undefined) { + builder.number('handshakeTimeoutMs', config.handshakeTimeoutMs) + } + + if (config.alertDaysBeforeExpiry !== undefined) { + builder.number('alertDaysBeforeExpiry', config.alertDaysBeforeExpiry) + } + + if (config.clientCertificateMode) { + builder.string('clientCertificateMode', config.clientCertificateMode) + } + + if (config.degradedResponseTimeMs !== undefined) { + builder.number('degradedResponseTimeMs', config.degradedResponseTimeMs) + } + + if (config.maxResponseTimeMs !== undefined) { + builder.number('maxResponseTimeMs', config.maxResponseTimeMs) + } + + if (config.securityBaseline) { + builder.value('securityBaseline', unknown(config.securityBaseline)) + } + }) + + if (request.sslClientCertificateId) { + builder.string('sslClientCertificateId', request.sslClientCertificateId) + } + + if (request.assertions) { + const assertions = request.assertions + if (assertions.length > 0) { + builder.array('assertions', builder => { + for (const assertion of assertions) { + builder.value(valueForSslAssertion(genfile, assertion)) + } + }) + } + } + }) +} diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts new file mode 100644 index 000000000..c3c01db44 --- /dev/null +++ b/packages/cli/src/constructs/ssl-request.ts @@ -0,0 +1,185 @@ +import { SslAssertion } from './ssl-assertion.js' +import { IPFamily } from './ip.js' + +/** + * The severity applied to an SSL security-baseline rule. + * + * - `fail` marks the monitor as failing when the rule is violated. + * - `warn` marks the monitor as degraded when the rule is violated. + * - `ignore` disables the rule. + */ +export type SslBaselineSeverity = 'fail' | 'warn' | 'ignore' + +/** + * A baseline rule whose value is a TLS version string (e.g. `TLS1.2`/`TLS1.3`). + */ +export interface SslBaselineTlsRule { + value?: string + severity?: SslBaselineSeverity +} + +/** + * A baseline rule whose value is a key size in bits. + */ +export interface SslBaselineKeySizeRule { + value?: number + severity?: SslBaselineSeverity +} + +/** + * A baseline rule that only carries a severity. + */ +export interface SslBaselineSeverityRule { + severity?: SslBaselineSeverity +} + +/** + * The SSL security baseline — a set of enforceable and advisory rules. Provide + * one only to override the server-side default baseline; omit it to inherit the + * account default. + */ +export interface SecurityBaseline { + /** + * Whether the security baseline is enforced. The server defaults this to + * `true` when omitted. + */ + enabled?: boolean + + // Enforceable rules — server default severity "fail". + minTLSVersion?: SslBaselineTlsRule + minKeySizeBits?: SslBaselineKeySizeRule + weakSignatureAlgorithm?: SslBaselineSeverityRule + weakCipherSuite?: SslBaselineSeverityRule + knownBadCA?: SslBaselineSeverityRule + + // Advisory rules — server default severity "ignore". + recommendedTLSVersion?: SslBaselineTlsRule + recommendedKeySizeBits?: SslBaselineKeySizeRule + ocspMustStapleRespected?: SslBaselineSeverityRule + sctPresent?: SslBaselineSeverityRule +} + +/** + * The mutual-TLS client-certificate mode. + * + * - `auto` lets Checkly select a stored certificate. + * - `explicit` uses the certificate referenced by `sslClientCertificateId`. + * + * Omit to inherit the account default (no certificate sent). + */ +export type SslClientCertificateMode = 'auto' | 'explicit' + +/** + * SSL-specific configuration nested inside an SSL monitor's request. + */ +export interface SslConfig { + /** + * The hostname to connect to and validate the TLS certificate of. Do not + * include a scheme or a port in this value. + * + * @example "example.com" + */ + hostname: string + + /** + * The port number to connect to. + * + * @minimum 1 + * @maximum 65535 + * @default 443 + */ + port?: number + + /** + * An optional SNI server name to send in the TLS handshake. Defaults to + * `hostname` when unset. + */ + serverName?: string + + /** + * The IP family to use when executing the check. + * + * @default "IPv4" + */ + ipFamily?: IPFamily + + /** + * When true, the certificate chain is not validated against trusted roots + * (the certificate is still inspected for expiry and the security baseline). + * + * @default false + */ + skipChainValidation?: boolean + + /** + * The number of milliseconds to wait for the TLS handshake to complete before + * timing out. + * + * @minimum 1000 + * @maximum 30000 + * @default 10000 + */ + handshakeTimeoutMs?: number + + /** + * Raise an alert when the certificate is within this many days of expiry. + * + * @minimum 1 + * @maximum 365 + * @default 20 + */ + alertDaysBeforeExpiry?: number + + /** + * The SSL security baseline. Omit to inherit the account default baseline. + */ + securityBaseline?: SecurityBaseline + + /** + * The mutual-TLS client-certificate mode. Omit to inherit the account default + * (no certificate sent). + */ + clientCertificateMode?: SslClientCertificateMode + + /** + * The handshake time in milliseconds above which the monitor is considered + * degraded. + * + * @minimum 0 + * @maximum 30000 + * @default 3000 + */ + degradedResponseTimeMs?: number + + /** + * The handshake time in milliseconds above which the monitor is considered + * failing. Must be greater than or equal to `degradedResponseTimeMs`. + * + * @minimum 0 + * @maximum 30000 + * @default 10000 + */ + maxResponseTimeMs?: number +} + +/** + * Configuration for SSL requests. + * Defines the connection parameters and validation rules. + */ +export interface SslRequest { + /** + * The SSL-specific configuration for the connection. + */ + sslConfig: SslConfig + + /** + * The ID of the stored client certificate to present. Required when + * `sslConfig.clientCertificateMode` is `explicit`. + */ + sslClientCertificateId?: string + + /** + * Assertions to validate the TLS certificate. + */ + assertions?: Array +} diff --git a/packages/cli/src/constructs/traceroute-assertion-codegen.ts b/packages/cli/src/constructs/traceroute-assertion-codegen.ts new file mode 100644 index 000000000..76f217ff0 --- /dev/null +++ b/packages/cli/src/constructs/traceroute-assertion-codegen.ts @@ -0,0 +1,18 @@ +import { GeneratedFile, Value } from '../sourcegen/index.js' +import { valueForNumericAssertion } from './internal/assertion-codegen.js' +import { TracerouteAssertion } from './traceroute-assertion.js' + +export function valueForTracerouteAssertion (genfile: GeneratedFile, assertion: TracerouteAssertion): Value { + genfile.namedImport('TracerouteAssertionBuilder', 'checkly/constructs') + + switch (assertion.source) { + case 'RESPONSE_TIME': + return valueForNumericAssertion('TracerouteAssertionBuilder', 'responseTime', assertion) + case 'HOP_COUNT': + return valueForNumericAssertion('TracerouteAssertionBuilder', 'hopCount', assertion) + case 'PACKET_LOSS': + return valueForNumericAssertion('TracerouteAssertionBuilder', 'packetLoss', assertion) + default: + throw new Error(`Unsupported traceroute assertion source ${assertion.source}`) + } +} diff --git a/packages/cli/src/constructs/traceroute-assertion.ts b/packages/cli/src/constructs/traceroute-assertion.ts new file mode 100644 index 000000000..c625559db --- /dev/null +++ b/packages/cli/src/constructs/traceroute-assertion.ts @@ -0,0 +1,50 @@ +import { Assertion as CoreAssertion, NumericAssertionBuilder } from './internal/assertion.js' + +type TracerouteAssertionSource = + | 'RESPONSE_TIME' + | 'HOP_COUNT' + | 'PACKET_LOSS' + +export type TracerouteAssertion = CoreAssertion + +/** + * Builder class for creating traceroute monitor assertions. + * Provides methods to create assertions for traceroute probe responses. + * + * @example + * ```typescript + * // Response time assertions + * TracerouteAssertionBuilder.responseTime().lessThan(1000) + * + * // Hop count assertions + * TracerouteAssertionBuilder.hopCount().lessThan(20) + * + * // Packet loss assertions (percentage, 0-100) + * TracerouteAssertionBuilder.packetLoss().lessThan(10) + * ``` + */ +export class TracerouteAssertionBuilder { + /** + * Creates an assertion builder for traceroute response time. + * @returns A numeric assertion builder for response time in milliseconds. + */ + static responseTime () { + return new NumericAssertionBuilder('RESPONSE_TIME') + } + + /** + * Creates an assertion builder for the number of network hops. + * @returns A numeric assertion builder for the hop count. + */ + static hopCount () { + return new NumericAssertionBuilder('HOP_COUNT') + } + + /** + * Creates an assertion builder for the percentage of packet loss (0-100). + * @returns A numeric assertion builder for packet loss. + */ + static packetLoss () { + return new NumericAssertionBuilder('PACKET_LOSS') + } +} diff --git a/packages/cli/src/constructs/traceroute-monitor-codegen.ts b/packages/cli/src/constructs/traceroute-monitor-codegen.ts new file mode 100644 index 000000000..1c704c860 --- /dev/null +++ b/packages/cli/src/constructs/traceroute-monitor-codegen.ts @@ -0,0 +1,50 @@ +import { Codegen, Context } from './internal/codegen/index.js' +import { expr, ident } from '../sourcegen/index.js' +import { buildMonitorProps, MonitorResource } from './monitor-codegen.js' +import { TracerouteRequest } from './traceroute-request.js' +import { valueForTracerouteRequest } from './traceroute-request-codegen.js' + +export interface TracerouteMonitorResource extends MonitorResource { + checkType: 'TRACEROUTE' + request: TracerouteRequest + degradedResponseTime?: number + maxResponseTime?: number +} + +const construct = 'TracerouteMonitor' + +export class TracerouteMonitorCodegen extends Codegen { + describe (resource: TracerouteMonitorResource): string { + return `Traceroute Monitor: ${resource.name}` + } + + gencode (logicalId: string, resource: TracerouteMonitorResource, context: Context): void { + const filePath = context.filePath('resources/traceroute-monitors', resource.name, { + tags: resource.tags, + unique: true, + }) + + const file = this.program.generatedConstructFile(filePath.fullPath) + + file.namedImport(construct, 'checkly/constructs') + + file.section(expr(ident(construct), builder => { + builder.new(builder => { + builder.string(logicalId) + builder.object(builder => { + if (resource.degradedResponseTime !== undefined) { + builder.number('degradedResponseTime', resource.degradedResponseTime) + } + + if (resource.maxResponseTime !== undefined) { + builder.number('maxResponseTime', resource.maxResponseTime) + } + + buildMonitorProps(this.program, file, builder, resource, context) + + builder.value('request', valueForTracerouteRequest(this.program, file, context, resource.request)) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/traceroute-monitor.ts b/packages/cli/src/constructs/traceroute-monitor.ts new file mode 100644 index 000000000..a38345cbc --- /dev/null +++ b/packages/cli/src/constructs/traceroute-monitor.ts @@ -0,0 +1,93 @@ +import { Monitor, MonitorProps } from './monitor.js' +import { Session } from './session.js' +import { Diagnostics } from './diagnostics.js' +import { validateResponseTimes } from './internal/common-diagnostics.js' +import { TracerouteRequest } from './traceroute-request.js' + +export interface TracerouteMonitorProps extends MonitorProps { + /** + * Determines the request that the monitor is going to run. + */ + request: TracerouteRequest + + /** + * The response time in milliseconds where the monitor should be considered + * degraded. + * + * @defaultValue 10000 + * @minimum 0 + * @maximum 30000 + * @example + * ```typescript + * degradedResponseTime: 5000 // Alert when the traceroute takes longer than 5 seconds + * ``` + */ + degradedResponseTime?: number + + /** + * The response time in milliseconds where the monitor should be considered + * failing. + * + * @defaultValue 20000 + * @minimum 0 + * @maximum 30000 + * @example + * ```typescript + * maxResponseTime: 20000 // Fail if the traceroute takes longer than 20 seconds + * ``` + */ + maxResponseTime?: number +} + +/** + * Creates a Traceroute Monitor + */ +export class TracerouteMonitor extends Monitor { + request: TracerouteRequest + degradedResponseTime?: number + maxResponseTime?: number + + /** + * Constructs the Traceroute Monitor instance + * + * @param logicalId unique project-scoped resource name identification + * @param props configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/traceroute-monitor/ Read more in the docs} + */ + + constructor (logicalId: string, props: TracerouteMonitorProps) { + super(logicalId, props) + + this.request = props.request + this.degradedResponseTime = props.degradedResponseTime + this.maxResponseTime = props.maxResponseTime + + Session.registerConstruct(this) + this.addSubscriptions() + this.addPrivateLocationCheckAssignments() + } + + describe (): string { + return `TracerouteMonitor:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + + await validateResponseTimes(diagnostics, this, { + degradedResponseTime: 30_000, + maxResponseTime: 30_000, + }) + } + + synthesize () { + return { + ...super.synthesize(), + checkType: 'TRACEROUTE', + request: this.request, + degradedResponseTime: this.degradedResponseTime, + maxResponseTime: this.maxResponseTime, + } + } +} diff --git a/packages/cli/src/constructs/traceroute-request-codegen.ts b/packages/cli/src/constructs/traceroute-request-codegen.ts new file mode 100644 index 000000000..78e24832a --- /dev/null +++ b/packages/cli/src/constructs/traceroute-request-codegen.ts @@ -0,0 +1,56 @@ +import { GeneratedFile, object, Program, Value } from '../sourcegen/index.js' +import { valueForTracerouteAssertion } from './traceroute-assertion-codegen.js' +import { TracerouteRequest } from './traceroute-request.js' +import { Context } from './internal/codegen/index.js' + +export function valueForTracerouteRequest ( + program: Program, + genfile: GeneratedFile, + context: Context, + request: TracerouteRequest, +): Value { + return object(builder => { + builder.string('url', request.url) + + if (request.protocol && request.protocol !== 'TCP') { + builder.string('protocol', request.protocol) + } + + // `port` is not sent for ICMP probes (the backend strips it), so only emit + // it for the protocols that use it. + if (request.port !== undefined && request.protocol !== 'ICMP') { + builder.number('port', request.port) + } + + if (request.ipFamily && request.ipFamily !== 'IPv4') { + builder.string('ipFamily', request.ipFamily) + } + + if (request.maxHops !== undefined) { + builder.number('maxHops', request.maxHops) + } + + if (request.maxUnknownHops !== undefined) { + builder.number('maxUnknownHops', request.maxUnknownHops) + } + + if (request.ptrLookup !== undefined) { + builder.boolean('ptrLookup', request.ptrLookup) + } + + if (request.timeout !== undefined) { + builder.number('timeout', request.timeout) + } + + if (request.assertions) { + const assertions = request.assertions + if (assertions.length > 0) { + builder.array('assertions', builder => { + for (const assertion of assertions) { + builder.value(valueForTracerouteAssertion(genfile, assertion)) + } + }) + } + } + }) +} diff --git a/packages/cli/src/constructs/traceroute-request.ts b/packages/cli/src/constructs/traceroute-request.ts new file mode 100644 index 000000000..8c9a59bc2 --- /dev/null +++ b/packages/cli/src/constructs/traceroute-request.ts @@ -0,0 +1,95 @@ +import { TracerouteAssertion } from './traceroute-assertion.js' +import { IPFamily } from './ip.js' + +/** + * The traceroute probe protocol. + * + * - `TCP` sends SYN probes (default). + * - `UDP` sends datagrams to a high port. + * - `ICMP` sends Echo Requests. + * - `SCTP` sends INIT chunks. + */ +export type TracerouteProtocol = + | 'TCP' + | 'UDP' + | 'ICMP' + | 'SCTP' + +/** + * Configuration for traceroute requests. + * Defines the probe parameters and validation rules. + */ +export interface TracerouteRequest { + /** + * The host to trace the network path to. Do not include a scheme or a port in + * this value. + * + * @example "example.com" + */ + url: string + + /** + * The probe protocol. + * + * @default "TCP" + */ + protocol?: TracerouteProtocol + + /** + * The destination port for TCP/UDP/SCTP probes. Ignored (and not sent) when + * `protocol` is `ICMP`. + * + * @minimum 1 + * @maximum 65535 + * @default 443 + */ + port?: number + + /** + * The IP family to use when executing the traceroute. + * + * @default "IPv4" + */ + ipFamily?: IPFamily + + /** + * The maximum number of network hops to probe before stopping. + * + * @minimum 1 + * @maximum 64 + * @default 30 + */ + maxHops?: number + + /** + * The maximum number of consecutive unresponsive hops to tolerate before + * stopping the trace. + * + * @minimum 1 + * @maximum 30 + * @default 15 + */ + maxUnknownHops?: number + + /** + * Whether to perform reverse-DNS (PTR) lookups on each hop's IP address. + * + * @default true + */ + ptrLookup?: boolean + + /** + * The number of seconds to wait for the traceroute to complete before timing + * out. + * + * @minimum 1 + * @maximum 30 + * @default 10 + */ + timeout?: number + + /** + * Assertions to validate the traceroute response. + */ + assertions?: Array +} From afc3a4b1932fdf5ac8775a307de1d63b4a31d011 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 26 Jun 2026 14:30:47 +0000 Subject: [PATCH 03/10] fix(deploy): skip code-bundle upload when nothing was bundled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit archive.store() (the 'Uploading Playwright tests' step) was called unconditionally on every deploy. For a project of only uptime monitors (GRPC/SSL/TRACEROUTE), the bundler registers no files, so this uploaded an empty Playwright bundle — an unnecessary code-bundle upload in production, and a hard failure in devenv where it 500s on the storage backend. The remote code bundle is consumed only by Playwright check suites (via bundler.marker -> playwright-check.ts); browser checks upload snapshots separately. Add Bundler.isEmpty and skip store() when no files were registered. Refs T65. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/deploy.ts | 22 ++++++++++++------- .../cli/src/services/check-parser/bundler.ts | 9 ++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 53277c613..f200e39be 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -193,14 +193,20 @@ export default class Deploy extends AuthCommand { const archive = await bundler.finalize() bundler.updateMarker(archive.archiveFile) - this.style.actionStart('Uploading Playwright tests') - try { - const storedArchive = await archive.store() - bundler.updateMarker(storedArchive.key) - this.style.actionSuccess() - } catch (err) { - this.style.actionFailure() - throw err + // The remote code bundle is only consumed by Playwright check suites (via + // bundler.marker). If nothing registered files to bundle (e.g. a project of + // only uptime monitors), there is nothing to upload — skip the store() to + // avoid an unnecessary code-bundle upload. + if (!bundler.isEmpty) { + this.style.actionStart('Uploading Playwright tests') + try { + const storedArchive = await archive.store() + bundler.updateMarker(storedArchive.key) + this.style.actionSuccess() + } catch (err) { + this.style.actionFailure() + throw err + } } const bundledChecksByType = { diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index e9169f9e5..d8c2ccb86 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -283,6 +283,15 @@ export class Bundler { return this.#cacheHash } + /** + * Whether any files have been registered for bundling. Only Playwright check + * suites register files (see playwright-check.ts), so an empty bundler means the + * project has nothing that needs a remote code bundle and the upload can be skipped. + */ + get isEmpty (): boolean { + return this.#files.size === 0 + } + registerFiles (...files: File[]): void { for (const newFile of files) { const existingFile = this.#files.get(newFile.filePath) From 6800a869ad1c2b5a4a7427fb0ba7e4e2a97d18d4 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Tue, 30 Jun 2026 12:29:52 +0200 Subject: [PATCH 04/10] feat(cli): wire gRPC/SSL/Traceroute analytics paths + connection-error rendering Addresses review (Simo) new-monitor checklist gaps: - src/rest/analytics.ts: add checkTypeToPath (grpc-checks/ssl/traceroute) and defaultMetrics for the three types, matching the backend analytics routes + metric registries (gRPC total_*, SSL handshakeTimeMs_*/daysUntilExpiry_avg, TRACEROUTE finalHopLatencyAvg_*/totalHops_avg). - src/reporters/util.ts: add the Connection Error subsection to the GRPC/SSL/ TRACEROUTE result blocks, mirroring ICMP/DNS. --- packages/cli/src/reporters/util.ts | 18 ++++++++++++++++++ packages/cli/src/rest/analytics.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 7d6754340..d750123c4 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -246,6 +246,12 @@ export function formatCheckResult (checkResult: any) { formatSectionTitle('Traceroute Response'), formatTracerouteResponse(checkResult.checkRunData.response), ]) + if (checkResult.checkRunData?.response?.error) { + result.push([ + formatSectionTitle('Connection Error'), + formatConnectionError(checkResult.checkRunData?.response?.error), + ]) + } } if (checkResult.checkRunData?.assertions?.length) { result.push([ @@ -265,6 +271,12 @@ export function formatCheckResult (checkResult: any) { formatSectionTitle('gRPC Response'), formatGrpcResponse(checkResult.checkRunData.response), ]) + if (checkResult.checkRunData?.response?.error) { + result.push([ + formatSectionTitle('Connection Error'), + formatConnectionError(checkResult.checkRunData?.response?.error), + ]) + } } if (checkResult.checkRunData?.assertions?.length) { result.push([ @@ -284,6 +296,12 @@ export function formatCheckResult (checkResult: any) { formatSectionTitle('SSL Response'), formatSslResponse(checkResult.checkRunData.response), ]) + if (checkResult.checkRunData?.response?.error) { + result.push([ + formatSectionTitle('Connection Error'), + formatConnectionError(checkResult.checkRunData?.response?.error), + ]) + } } if (checkResult.checkRunData?.assertions?.length) { result.push([ diff --git a/packages/cli/src/rest/analytics.ts b/packages/cli/src/rest/analytics.ts index f2fb13b3a..1202675d9 100644 --- a/packages/cli/src/rest/analytics.ts +++ b/packages/cli/src/rest/analytics.ts @@ -20,6 +20,9 @@ const checkTypeToPath: Partial> = { [CheckTypes.DNS]: 'dns', [CheckTypes.URL]: 'url-monitors', [CheckTypes.AGENTIC]: 'agentic-checks', + [CheckTypes.GRPC]: 'grpc-checks', + [CheckTypes.SSL]: 'ssl', + [CheckTypes.TRACEROUTE]: 'traceroute', } // Default aggregated metrics per check type @@ -32,6 +35,9 @@ const defaultMetrics: Partial> = { [CheckTypes.TCP]: ['availability', 'total_avg', 'total_p50', 'total_p95', 'total_p99'], [CheckTypes.DNS]: ['availability', 'total_avg', 'total_p50', 'total_p95', 'total_p99'], [CheckTypes.ICMP]: ['availability', 'packetLoss_avg', 'latencyAvg_avg', 'latencyAvg_p50', 'latencyAvg_p95', 'latencyAvg_p99'], + [CheckTypes.GRPC]: ['availability', 'total_avg', 'total_p50', 'total_p95', 'total_p99'], + [CheckTypes.SSL]: ['availability', 'handshakeTimeMs_avg', 'handshakeTimeMs_p50', 'handshakeTimeMs_p95', 'handshakeTimeMs_p99', 'daysUntilExpiry_avg'], + [CheckTypes.TRACEROUTE]: ['availability', 'finalHopLatencyAvg_avg', 'finalHopLatencyAvg_p50', 'finalHopLatencyAvg_p95', 'finalHopLatencyAvg_p99', 'totalHops_avg'], [CheckTypes.HEARTBEAT]: ['availability'], [CheckTypes.AGENTIC]: ['availability'], } From 8358b22e92e71a120908ef498028ef84fee802f2 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Tue, 30 Jun 2026 12:30:05 +0200 Subject: [PATCH 05/10] docs(cli): AI context references + examples for gRPC/SSL/Traceroute monitors Addresses review (Simo) new-monitor checklist Phases 5 & 7: - ai-context/references/configure-{grpc,ssl,traceroute}-monitors.md + REFERENCES and EXAMPLE_CONFIGS entries in context.ts (inline exampleConfig). - examples/advanced-project{,-js}/src/__checks__/uptime/{grpc,ssl,traceroute} TS + JS examples with group, assertions, and doc-link comments. --- .../src/__checks__/uptime/grpc.check.js | 26 ++++++ .../src/__checks__/uptime/ssl.check.js | 27 ++++++ .../src/__checks__/uptime/traceroute.check.js | 24 +++++ .../src/__checks__/uptime/grpc.check.ts | 26 ++++++ .../src/__checks__/uptime/ssl.check.ts | 27 ++++++ .../src/__checks__/uptime/traceroute.check.ts | 24 +++++ packages/cli/src/ai-context/context.ts | 89 +++++++++++++++++++ .../references/configure-grpc-monitors.md | 13 +++ .../references/configure-ssl-monitors.md | 13 +++ .../configure-traceroute-monitors.md | 13 +++ 10 files changed, 282 insertions(+) create mode 100644 examples/advanced-project-js/src/__checks__/uptime/grpc.check.js create mode 100644 examples/advanced-project-js/src/__checks__/uptime/ssl.check.js create mode 100644 examples/advanced-project-js/src/__checks__/uptime/traceroute.check.js create mode 100644 examples/advanced-project/src/__checks__/uptime/grpc.check.ts create mode 100644 examples/advanced-project/src/__checks__/uptime/ssl.check.ts create mode 100644 examples/advanced-project/src/__checks__/uptime/traceroute.check.ts create mode 100644 packages/cli/src/ai-context/references/configure-grpc-monitors.md create mode 100644 packages/cli/src/ai-context/references/configure-ssl-monitors.md create mode 100644 packages/cli/src/ai-context/references/configure-traceroute-monitors.md diff --git a/examples/advanced-project-js/src/__checks__/uptime/grpc.check.js b/examples/advanced-project-js/src/__checks__/uptime/grpc.check.js new file mode 100644 index 000000000..ccf722316 --- /dev/null +++ b/examples/advanced-project-js/src/__checks__/uptime/grpc.check.js @@ -0,0 +1,26 @@ +const { GrpcMonitor, GrpcAssertionBuilder } = require('checkly/constructs') +const { uptimeGroup } = require('../utils/website-groups.check') + +// gRPC monitors check gRPC service health or invoke unary methods to validate responses. +// They support HEALTH mode (standard gRPC health-check) and BEHAVIOR mode (custom method calls). +// Read more: https://www.checklyhq.com/docs/grpc-monitors/ + +new GrpcMonitor('grpc-api-health', { + name: 'gRPC API Health Monitor', + activated: true, + group: uptimeGroup, + degradedResponseTime: 2000, + maxResponseTime: 5000, + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'HEALTH', + tls: true, + }, + assertions: [ + GrpcAssertionBuilder.healthCheckStatus().equals('SERVING'), + GrpcAssertionBuilder.responseTime().lessThan(1000), + ], + }, +}) diff --git a/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js new file mode 100644 index 000000000..d8bcfc6a7 --- /dev/null +++ b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js @@ -0,0 +1,27 @@ +const { SslMonitor, SslAssertionBuilder } = require('checkly/constructs') +const { uptimeGroup } = require('../utils/website-groups.check') + +// SSL monitors validate TLS certificates: expiry, chain trust, TLS version, and more. +// Configure alertDaysBeforeExpiry to be notified before a certificate expires. +// Read more: https://www.checklyhq.com/docs/ssl-monitors/ + +new SslMonitor('example-com-ssl', { + name: 'example.com SSL Certificate', + activated: true, + group: uptimeGroup, + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + alertDaysBeforeExpiry: 30, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + assertions: [ + SslAssertionBuilder.certExpiresInDays().greaterThan(30), + SslAssertionBuilder.chainTrusted().equals(true), + SslAssertionBuilder.hostnameVerified().equals(true), + SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + ], + }, +}) diff --git a/examples/advanced-project-js/src/__checks__/uptime/traceroute.check.js b/examples/advanced-project-js/src/__checks__/uptime/traceroute.check.js new file mode 100644 index 000000000..d28eeb95e --- /dev/null +++ b/examples/advanced-project-js/src/__checks__/uptime/traceroute.check.js @@ -0,0 +1,24 @@ +const { TracerouteMonitor, TracerouteAssertionBuilder } = require('checkly/constructs') +const { uptimeGroup } = require('../utils/website-groups.check') + +// Traceroute monitors map the network path to a host and can detect routing issues, +// excessive hops, or high packet loss along the path. +// Read more: https://www.checklyhq.com/docs/traceroute-monitors/ + +new TracerouteMonitor('example-com-traceroute', { + name: 'example.com Traceroute', + activated: true, + group: uptimeGroup, + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + maxHops: 30, + assertions: [ + TracerouteAssertionBuilder.hopCount().lessThan(20), + TracerouteAssertionBuilder.packetLoss().lessThan(10), + ], + }, +}) diff --git a/examples/advanced-project/src/__checks__/uptime/grpc.check.ts b/examples/advanced-project/src/__checks__/uptime/grpc.check.ts new file mode 100644 index 000000000..b9eed3b35 --- /dev/null +++ b/examples/advanced-project/src/__checks__/uptime/grpc.check.ts @@ -0,0 +1,26 @@ +import { GrpcMonitor, GrpcAssertionBuilder } from 'checkly/constructs' +import { uptimeGroup } from '../utils/website-groups.check' + +// gRPC monitors check gRPC service health or invoke unary methods to validate responses. +// They support HEALTH mode (standard gRPC health-check) and BEHAVIOR mode (custom method calls). +// Read more: https://www.checklyhq.com/docs/grpc-monitors/ + +new GrpcMonitor('grpc-api-health', { + name: 'gRPC API Health Monitor', + activated: true, + group: uptimeGroup, + degradedResponseTime: 2000, + maxResponseTime: 5000, + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'HEALTH', + tls: true, + }, + assertions: [ + GrpcAssertionBuilder.healthCheckStatus().equals('SERVING'), + GrpcAssertionBuilder.responseTime().lessThan(1000), + ], + }, +}) diff --git a/examples/advanced-project/src/__checks__/uptime/ssl.check.ts b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts new file mode 100644 index 000000000..cfe5052fa --- /dev/null +++ b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts @@ -0,0 +1,27 @@ +import { SslMonitor, SslAssertionBuilder } from 'checkly/constructs' +import { uptimeGroup } from '../utils/website-groups.check' + +// SSL monitors validate TLS certificates: expiry, chain trust, TLS version, and more. +// Configure alertDaysBeforeExpiry to be notified before a certificate expires. +// Read more: https://www.checklyhq.com/docs/ssl-monitors/ + +new SslMonitor('example-com-ssl', { + name: 'example.com SSL Certificate', + activated: true, + group: uptimeGroup, + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + alertDaysBeforeExpiry: 30, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + assertions: [ + SslAssertionBuilder.certExpiresInDays().greaterThan(30), + SslAssertionBuilder.chainTrusted().equals(true), + SslAssertionBuilder.hostnameVerified().equals(true), + SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + ], + }, +}) diff --git a/examples/advanced-project/src/__checks__/uptime/traceroute.check.ts b/examples/advanced-project/src/__checks__/uptime/traceroute.check.ts new file mode 100644 index 000000000..f40db2397 --- /dev/null +++ b/examples/advanced-project/src/__checks__/uptime/traceroute.check.ts @@ -0,0 +1,24 @@ +import { TracerouteMonitor, TracerouteAssertionBuilder } from 'checkly/constructs' +import { uptimeGroup } from '../utils/website-groups.check' + +// Traceroute monitors map the network path to a host and can detect routing issues, +// excessive hops, or high packet loss along the path. +// Read more: https://www.checklyhq.com/docs/traceroute-monitors/ + +new TracerouteMonitor('example-com-traceroute', { + name: 'example.com Traceroute', + activated: true, + group: uptimeGroup, + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + maxHops: 30, + assertions: [ + TracerouteAssertionBuilder.hopCount().lessThan(20), + TracerouteAssertionBuilder.packetLoss().lessThan(10), + ], + }, +}) diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index e6e563056..5f3e3e44c 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -35,6 +35,18 @@ export const REFERENCES = [ id: 'configure-icmp-monitors', description: 'ICMP Monitor construct (`IcmpMonitor`) with latency and packet loss assertions', }, + { + id: 'configure-grpc-monitors', + description: 'gRPC Monitor construct (`GrpcMonitor`) with status code, response message, and health-check assertions', + }, + { + id: 'configure-ssl-monitors', + description: 'SSL Monitor construct (`SslMonitor`) with certificate expiry, chain trust, and TLS version assertions', + }, + { + id: 'configure-traceroute-monitors', + description: 'Traceroute Monitor construct (`TracerouteMonitor`) with hop count and packet loss assertions', + }, { id: 'configure-heartbeat-monitors', description: 'Heartbeat Monitor construct (`HeartbeatMonitor`)', @@ -235,6 +247,83 @@ const playwrightChecks = new PlaywrightCheck("multi-browser-check", { exampleConfigPath: 'resources/icmp-monitors/example-icmp-monitor.check.ts', reference: 'https://www.checklyhq.com/docs/constructs/icmp-monitor/', }, + GRPC_MONITOR: { + templateString: '', + exampleConfig: `import { GrpcMonitor, GrpcAssertionBuilder } from 'checkly/constructs' + +new GrpcMonitor('grpc-api-health', { + name: 'gRPC API Health', + activated: true, + locations: ['us-east-1', 'eu-west-1'], + degradedResponseTime: 2000, + maxResponseTime: 5000, + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'HEALTH', + tls: true, + }, + assertions: [ + GrpcAssertionBuilder.healthCheckStatus().equals('SERVING'), + GrpcAssertionBuilder.responseTime().lessThan(1000), + ], + }, +}) +`, + reference: 'https://www.checklyhq.com/docs/constructs/grpc-monitor/', + }, + SSL_MONITOR: { + templateString: '', + exampleConfig: `import { SslMonitor, SslAssertionBuilder } from 'checkly/constructs' + +new SslMonitor('example-com-ssl', { + name: 'example.com SSL Certificate', + activated: true, + locations: ['us-east-1', 'eu-west-1'], + request: { + sslConfig: { + hostname: 'example.com', + port: 443, + alertDaysBeforeExpiry: 30, + degradedResponseTimeMs: 3000, + maxResponseTimeMs: 10000, + }, + assertions: [ + SslAssertionBuilder.certExpiresInDays().greaterThan(30), + SslAssertionBuilder.chainTrusted().equals(true), + SslAssertionBuilder.hostnameVerified().equals(true), + SslAssertionBuilder.tlsVersion().equals('TLS1.3'), + ], + }, +}) +`, + reference: 'https://www.checklyhq.com/docs/constructs/ssl-monitor/', + }, + TRACEROUTE_MONITOR: { + templateString: '', + exampleConfig: `import { TracerouteMonitor, TracerouteAssertionBuilder } from 'checkly/constructs' + +new TracerouteMonitor('example-com-traceroute', { + name: 'example.com Traceroute', + activated: true, + locations: ['us-east-1', 'eu-west-1'], + degradedResponseTime: 10000, + maxResponseTime: 20000, + request: { + url: 'example.com', + protocol: 'TCP', + port: 443, + maxHops: 30, + assertions: [ + TracerouteAssertionBuilder.hopCount().lessThan(20), + TracerouteAssertionBuilder.packetLoss().lessThan(10), + ], + }, +}) +`, + reference: 'https://www.checklyhq.com/docs/constructs/traceroute-monitor/', + }, CHECK_GROUP: { templateString: '', exampleConfigPath: diff --git a/packages/cli/src/ai-context/references/configure-grpc-monitors.md b/packages/cli/src/ai-context/references/configure-grpc-monitors.md new file mode 100644 index 000000000..7140beab0 --- /dev/null +++ b/packages/cli/src/ai-context/references/configure-grpc-monitors.md @@ -0,0 +1,13 @@ +# gRPC Monitor + +- Import the `GrpcMonitor` construct from `checkly/constructs`. +- Reference [the docs for gRPC monitors](https://www.checklyhq.com/docs/constructs/grpc-monitor/) before generating any code. +- When adding `assertions`, always use `GrpcAssertionBuilder` class. +- The `request` object must include a `url` (hostname only, no scheme), `port`, and a `grpcConfig` object. +- Use `grpcConfig.mode` to choose between `'BEHAVIOR'` (invoke a unary method) and `'HEALTH'` (standard health-check service). +- In `BEHAVIOR` mode, set `grpcConfig.method` (e.g. `'package.Service/Method'`). Use `grpcConfig.serviceDefinition` (`'REFLECTION'` or `'PROTO_FILE'`) to resolve the service definition. +- In `HEALTH` mode, optionally set `grpcConfig.service` to query a specific service; omit it to query overall server health. +- Use `degradedResponseTime` and `maxResponseTime` (milliseconds) to configure response time thresholds. +- **Plan-gated properties:** `retryStrategy`, `runParallel`, and higher frequencies are not available on all plans. Check entitlements matching `UPTIME_CHECKS_*` before using these. Omit any property whose entitlement is disabled. See `npx checkly skills manage` for details. + + diff --git a/packages/cli/src/ai-context/references/configure-ssl-monitors.md b/packages/cli/src/ai-context/references/configure-ssl-monitors.md new file mode 100644 index 000000000..5235c55ea --- /dev/null +++ b/packages/cli/src/ai-context/references/configure-ssl-monitors.md @@ -0,0 +1,13 @@ +# SSL Monitor + +- Import the `SslMonitor` construct from `checkly/constructs`. +- Reference [the docs for SSL monitors](https://www.checklyhq.com/docs/constructs/ssl-monitor/) before generating any code. +- When adding `assertions`, always use `SslAssertionBuilder` class. +- The `request` object must include a `sslConfig` object. The `sslConfig.hostname` field is required (hostname only, no scheme). +- Use `sslConfig.alertDaysBeforeExpiry` to raise an alert before the certificate expires (default: 20 days). +- Use `sslConfig.degradedResponseTimeMs` and `sslConfig.maxResponseTimeMs` to configure TLS handshake time thresholds. +- Use `sslConfig.securityBaseline` to enforce TLS version, key size, cipher suite, and other certificate quality rules. +- For mutual TLS, set `sslConfig.clientCertificateMode` to `'auto'` or `'explicit'`. When `'explicit'`, also set `sslClientCertificateId`. +- **Plan-gated properties:** `retryStrategy`, `runParallel`, and higher frequencies are not available on all plans. Check entitlements matching `UPTIME_CHECKS_*` before using these. Omit any property whose entitlement is disabled. See `npx checkly skills manage` for details. + + diff --git a/packages/cli/src/ai-context/references/configure-traceroute-monitors.md b/packages/cli/src/ai-context/references/configure-traceroute-monitors.md new file mode 100644 index 000000000..993d78106 --- /dev/null +++ b/packages/cli/src/ai-context/references/configure-traceroute-monitors.md @@ -0,0 +1,13 @@ +# Traceroute Monitor + +- Import the `TracerouteMonitor` construct from `checkly/constructs`. +- Reference [the docs for Traceroute monitors](https://www.checklyhq.com/docs/constructs/traceroute-monitor/) before generating any code. +- When adding `assertions`, always use `TracerouteAssertionBuilder` class. +- The `request` object must include a `url` field (hostname only, no scheme or port). +- Use `request.protocol` to choose the probe protocol: `'TCP'` (default), `'UDP'`, `'ICMP'`, or `'SCTP'`. +- Use `request.maxHops` (default: 30) and `request.maxUnknownHops` (default: 15) to control trace depth. +- Use `degradedResponseTime` and `maxResponseTime` (milliseconds) to configure response time thresholds. +- Traceroute assertions support `RESPONSE_TIME`, `HOP_COUNT`, and `PACKET_LOSS` sources. +- **Plan-gated properties:** `retryStrategy`, `runParallel`, and higher frequencies are not available on all plans. Check entitlements matching `UPTIME_CHECKS_*` before using these. Omit any property whose entitlement is disabled. See `npx checkly skills manage` for details. + + From 6488ce7ed3db92ad6ca02f374133a2682ebd2858 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Tue, 30 Jun 2026 12:30:05 +0200 Subject: [PATCH 06/10] test(cli): e2e deploy fixtures + Create assertions for gRPC/SSL/Traceroute Addresses review (Simo) new-monitor checklist Phase 6: - e2e/__tests__/fixtures/deploy-project/{grpc,ssl,traceroute}.check.ts (activated:false) - deploy.spec.ts: assert the three logical IDs in the Create output. --- packages/cli/e2e/__tests__/deploy.spec.ts | 6 ++++++ .../__tests__/fixtures/deploy-project/grpc.check.ts | 13 +++++++++++++ .../__tests__/fixtures/deploy-project/ssl.check.ts | 11 +++++++++++ .../fixtures/deploy-project/traceroute.check.ts | 9 +++++++++ 4 files changed, 39 insertions(+) create mode 100644 packages/cli/e2e/__tests__/fixtures/deploy-project/grpc.check.ts create mode 100644 packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts create mode 100644 packages/cli/e2e/__tests__/fixtures/deploy-project/traceroute.check.ts diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index 20bb74503..341ad08a5 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -205,10 +205,13 @@ describe('deploy', { timeout: 45_000 }, () => { DnsMonitor: dns-nonexistent-all-assertion-types DnsMonitor: dns-welcome-a DnsMonitor: dns-welcome-aaaa + GrpcMonitor: grpc-monitor HeartbeatMonitor: heartbeat-monitor-1 BrowserCheck: homepage-browser-check IcmpMonitor: icmp-welcome + SslMonitor: ssl-monitor TcpMonitor: tcp-monitor + TracerouteMonitor: traceroute-monitor CheckGroupV2: my-group-1 CheckGroupV1: my-group-2-v1 Dashboard: dashboard-1 @@ -227,11 +230,14 @@ describe('deploy', { timeout: 45_000 }, () => { DnsMonitor: dns-nonexistent-all-assertion-types DnsMonitor: dns-welcome-a DnsMonitor: dns-welcome-aaaa + GrpcMonitor: grpc-monitor HeartbeatMonitor: heartbeat-monitor-1 BrowserCheck: homepage-browser-check IcmpMonitor: icmp-welcome BrowserCheck: snapshot-test.test.ts + SslMonitor: ssl-monitor TcpMonitor: tcp-monitor + TracerouteMonitor: traceroute-monitor CheckGroupV2: my-group-1 CheckGroupV1: my-group-2-v1 Dashboard: dashboard-1 diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/grpc.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/grpc.check.ts new file mode 100644 index 000000000..a29cd2591 --- /dev/null +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/grpc.check.ts @@ -0,0 +1,13 @@ +import { GrpcMonitor } from 'checkly/constructs' + +new GrpcMonitor('grpc-monitor', { + name: 'gRPC Monitor', + activated: false, + request: { + url: 'grpc.example.com', + port: 50051, + grpcConfig: { + mode: 'HEALTH', + }, + }, +}) diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts new file mode 100644 index 000000000..d12d96d4c --- /dev/null +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts @@ -0,0 +1,11 @@ +import { SslMonitor } from 'checkly/constructs' + +new SslMonitor('ssl-monitor', { + name: 'SSL Monitor', + activated: false, + request: { + sslConfig: { + hostname: 'example.com', + }, + }, +}) diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/traceroute.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/traceroute.check.ts new file mode 100644 index 000000000..3902a9c63 --- /dev/null +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/traceroute.check.ts @@ -0,0 +1,9 @@ +import { TracerouteMonitor } from 'checkly/constructs' + +new TracerouteMonitor('traceroute-monitor', { + name: 'Traceroute Monitor', + activated: false, + request: { + url: 'example.com', + }, +}) From 51bf278c19e7ad5017e1b9dc0dd4fbb701b41f21 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Thu, 2 Jul 2026 12:54:19 +0200 Subject: [PATCH 07/10] feat(cli): support TEXT_BODY assertions for gRPC monitors Mirror monorepo #2730 which added TEXT_BODY to grpcMonitorAssertionSources. Adds GrpcAssertionBuilder.textBody() (source TEXT_BODY) + codegen case, matching the API check's textBody() builder. --- packages/cli/src/constructs/grpc-assertion-codegen.ts | 5 +++++ packages/cli/src/constructs/grpc-assertion.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/cli/src/constructs/grpc-assertion-codegen.ts b/packages/cli/src/constructs/grpc-assertion-codegen.ts index cff02396a..246c77239 100644 --- a/packages/cli/src/constructs/grpc-assertion-codegen.ts +++ b/packages/cli/src/constructs/grpc-assertion-codegen.ts @@ -20,6 +20,11 @@ export function valueForGrpcAssertion (genfile: GeneratedFile, assertion: GrpcAs hasProperty: true, hasRegex: false, }) + case 'TEXT_BODY': + return valueForGeneralAssertion('GrpcAssertionBuilder', 'textBody', assertion, { + hasProperty: true, + hasRegex: false, + }) case 'GRPC_METADATA': return valueForGeneralAssertion('GrpcAssertionBuilder', 'responseMetadata', assertion, { hasProperty: true, diff --git a/packages/cli/src/constructs/grpc-assertion.ts b/packages/cli/src/constructs/grpc-assertion.ts index a44bec7b9..999d7b04f 100644 --- a/packages/cli/src/constructs/grpc-assertion.ts +++ b/packages/cli/src/constructs/grpc-assertion.ts @@ -5,6 +5,7 @@ type GrpcAssertionSource = | 'GRPC_STATUS_CODE' | 'GRPC_HEALTHCHECK_STATUS' | 'GRPC_RESPONSE' + | 'TEXT_BODY' | 'GRPC_METADATA' export type GrpcAssertion = CoreAssertion @@ -65,6 +66,15 @@ export class GrpcAssertionBuilder { return new GeneralAssertionBuilder('GRPC_RESPONSE', property) } + /** + * Creates an assertion builder for the raw gRPC response body as text (BEHAVIOR mode). + * @param property Optional property path for text content. + * @returns A general assertion builder for the text response body. + */ + static textBody (property?: string) { + return new GeneralAssertionBuilder('TEXT_BODY', property) + } + /** * Creates an assertion builder for gRPC response metadata (headers). * @param property Optional metadata key to assert against. From 4c9ad1fe88012a618983f732eb6aa83255f3979e Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Thu, 2 Jul 2026 13:03:38 +0200 Subject: [PATCH 08/10] refactor(cli): restructure SslMonitor props per review (wire shape unchanged) Per @sorccu's review: make SslMonitor ergonomics consistent with other monitors. - degradedResponseTime/maxResponseTime -> construct top level (were in sslConfig) - hostname/port/ipFamily -> request top level (were in sslConfig) - sslClientCertificateId -> into sslConfig (was on request) synthesize() remaps to the identical API wire shape (request.sslConfig.* + request.sslClientCertificateId), so the payload the backend receives is unchanged. Updated codegen (wire->construct), unit + codegen + export-snippet tests, examples (ts+js), e2e fixture, and AI context. --- .../src/__checks__/uptime/ssl.check.js | 8 +- .../src/__checks__/uptime/ssl.check.ts | 8 +- .../fixtures/deploy-project/ssl.check.ts | 5 +- packages/cli/src/ai-context/context.ts | 8 +- .../references/configure-ssl-monitors.md | 7 +- .../constructs/__tests__/ssl-monitor.spec.ts | 79 +++++++++++++++---- .../__tests__/uptime-monitor-codegen.spec.ts | 12 +-- .../uptime-monitor-export-snippets.spec.ts | 10 +-- .../cli/src/constructs/ssl-monitor-codegen.ts | 58 +++++++++++++- packages/cli/src/constructs/ssl-monitor.ts | 58 +++++++++++--- .../cli/src/constructs/ssl-request-codegen.ts | 32 +++----- packages/cli/src/constructs/ssl-request.ts | 76 +++++++----------- 12 files changed, 234 insertions(+), 127 deletions(-) diff --git a/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js index d8bcfc6a7..f9f1a183f 100644 --- a/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js +++ b/examples/advanced-project-js/src/__checks__/uptime/ssl.check.js @@ -9,13 +9,13 @@ new SslMonitor('example-com-ssl', { name: 'example.com SSL Certificate', activated: true, group: uptimeGroup, + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { + hostname: 'example.com', + port: 443, sslConfig: { - hostname: 'example.com', - port: 443, alertDaysBeforeExpiry: 30, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, assertions: [ SslAssertionBuilder.certExpiresInDays().greaterThan(30), diff --git a/examples/advanced-project/src/__checks__/uptime/ssl.check.ts b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts index cfe5052fa..39155094d 100644 --- a/examples/advanced-project/src/__checks__/uptime/ssl.check.ts +++ b/examples/advanced-project/src/__checks__/uptime/ssl.check.ts @@ -9,13 +9,13 @@ new SslMonitor('example-com-ssl', { name: 'example.com SSL Certificate', activated: true, group: uptimeGroup, + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { + hostname: 'example.com', + port: 443, sslConfig: { - hostname: 'example.com', - port: 443, alertDaysBeforeExpiry: 30, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, assertions: [ SslAssertionBuilder.certExpiresInDays().greaterThan(30), diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts index d12d96d4c..a81625e3c 100644 --- a/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/ssl.check.ts @@ -4,8 +4,7 @@ new SslMonitor('ssl-monitor', { name: 'SSL Monitor', activated: false, request: { - sslConfig: { - hostname: 'example.com', - }, + hostname: 'example.com', + sslConfig: {}, }, }) diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index 5f3e3e44c..c4d28d784 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -281,13 +281,13 @@ new SslMonitor('example-com-ssl', { name: 'example.com SSL Certificate', activated: true, locations: ['us-east-1', 'eu-west-1'], + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { + hostname: 'example.com', + port: 443, sslConfig: { - hostname: 'example.com', - port: 443, alertDaysBeforeExpiry: 30, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, assertions: [ SslAssertionBuilder.certExpiresInDays().greaterThan(30), diff --git a/packages/cli/src/ai-context/references/configure-ssl-monitors.md b/packages/cli/src/ai-context/references/configure-ssl-monitors.md index 5235c55ea..4f7622bb5 100644 --- a/packages/cli/src/ai-context/references/configure-ssl-monitors.md +++ b/packages/cli/src/ai-context/references/configure-ssl-monitors.md @@ -3,11 +3,12 @@ - Import the `SslMonitor` construct from `checkly/constructs`. - Reference [the docs for SSL monitors](https://www.checklyhq.com/docs/constructs/ssl-monitor/) before generating any code. - When adding `assertions`, always use `SslAssertionBuilder` class. -- The `request` object must include a `sslConfig` object. The `sslConfig.hostname` field is required (hostname only, no scheme). +- The `request` object must include `hostname` (required, hostname only, no scheme) and a `sslConfig` object. +- Use `request.port` to set the port (default: 443). Use `request.ipFamily` to choose `'IPv4'` or `'IPv6'`. - Use `sslConfig.alertDaysBeforeExpiry` to raise an alert before the certificate expires (default: 20 days). -- Use `sslConfig.degradedResponseTimeMs` and `sslConfig.maxResponseTimeMs` to configure TLS handshake time thresholds. +- Use top-level `degradedResponseTime` and `maxResponseTime` (on `SslMonitorProps`) to configure TLS handshake time thresholds. - Use `sslConfig.securityBaseline` to enforce TLS version, key size, cipher suite, and other certificate quality rules. -- For mutual TLS, set `sslConfig.clientCertificateMode` to `'auto'` or `'explicit'`. When `'explicit'`, also set `sslClientCertificateId`. +- For mutual TLS, set `sslConfig.clientCertificateMode` to `'auto'` or `'explicit'`. When `'explicit'`, also set `sslConfig.sslClientCertificateId`. - **Plan-gated properties:** `retryStrategy`, `runParallel`, and higher frequencies are not available on all plans. Check entitlements matching `UPTIME_CHECKS_*` before using these. Omit any property whose entitlement is disabled. See `npx checkly skills manage` for details. diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index b3153013f..cea5843b2 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -6,10 +6,9 @@ import { Session } from '../session.js' import { Bundler } from '../../services/check-parser/bundler.js' const request: SslRequest = { - sslConfig: { - hostname: 'example.com', - port: 443, - }, + hostname: 'example.com', + port: 443, + sslConfig: {}, } function setupProject () { @@ -31,20 +30,20 @@ describe('SslMonitor', () => { expect(check).toMatchObject({ tags: ['default tags'] }) }) - it('should synthesize the SSL check type with nested config response times', () => { + it('should synthesize the SSL check type with top-level response times mapped to wire sslConfig', () => { setupProject() const check = new SslMonitor('test-check', { name: 'Test Check', + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4', sslConfig: { - hostname: 'example.com', - port: 443, - ipFamily: 'IPv4', skipChainValidation: false, handshakeTimeoutMs: 10000, alertDaysBeforeExpiry: 20, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, assertions: [ SslAssertionBuilder.certExpiresInDays().greaterThan(20), @@ -69,11 +68,41 @@ describe('SslMonitor', () => { ], }, }) - // SSL monitors carry no top-level response-time fields. + // Response times are top-level on the construct but mapped to wire sslConfig. expect(payload.degradedResponseTime).toBeUndefined() expect(payload.maxResponseTime).toBeUndefined() }) + it('should synthesize the correct wire shape with hostname and ipFamily inside sslConfig', () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + degradedResponseTime: 3000, + maxResponseTime: 10000, + request: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv6', + sslConfig: { + serverName: 'example.com', + sslClientCertificateId: 'clientcert_1234', + alertDaysBeforeExpiry: 30, + skipChainValidation: false, + }, + assertions: [ + SslAssertionBuilder.certExpiresInDays().greaterThan(30), + ], + }, + }) + + const payload = check.synthesize() as any + expect(payload.request.sslConfig.hostname).toBe('example.com') + expect(payload.request.sslConfig.ipFamily).toBe('IPv6') + expect(payload.request.sslConfig.degradedResponseTimeMs).toBe(3000) + expect(payload.request.sslConfig.maxResponseTimeMs).toBe(10000) + expect(payload.request.sslClientCertificateId).toBe('clientcert_1234') + }) + it('should support setting groups with `group`', async () => { setupProject() const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) @@ -93,8 +122,8 @@ describe('SslMonitor', () => { const check = new SslMonitor('test-check', { name: 'Test Check', request: { + hostname: 'example.com', sslConfig: { - hostname: 'example.com', clientCertificateMode: 'explicit', }, }, @@ -109,24 +138,40 @@ describe('SslMonitor', () => { ])) }) - it('should error when degradedResponseTimeMs exceeds maxResponseTimeMs', async () => { + it('should not error when clientCertificateMode is explicit and certificate id is set', async () => { setupProject() const check = new SslMonitor('test-check', { name: 'Test Check', request: { + hostname: 'example.com', sslConfig: { - hostname: 'example.com', - degradedResponseTimeMs: 8000, - maxResponseTimeMs: 5000, + clientCertificateMode: 'explicit', + sslClientCertificateId: 'clientcert_1234', }, }, }) const diags = new Diagnostics() await check.validate(diags) + expect(diags.isFatal()).toEqual(false) + }) + + it('should error when degradedResponseTime exceeds maxResponseTime', async () => { + setupProject() + const check = new SslMonitor('test-check', { + name: 'Test Check', + degradedResponseTime: 8000, + maxResponseTime: 5000, + request: { + hostname: 'example.com', + sslConfig: {}, + }, + }) + const diags = new Diagnostics() + await check.validate(diags) expect(diags.isFatal()).toEqual(true) expect(diags.observations).toEqual(expect.arrayContaining([ expect.objectContaining({ - message: expect.stringContaining('must be less than or equal to "maxResponseTimeMs"'), + message: expect.stringContaining('must be less than or equal to "maxResponseTime"'), }), ])) }) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 74d1765db..2c062fae4 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -155,14 +155,16 @@ describe('SslMonitorCodegen', () => { expect(source).toContain('from \'checkly/constructs\'') expect(source).toContain('new SslMonitor(\'ssl-check\'') expect(source).toContain('sslConfig: {') + // hostname/port are now top-level in request, not in sslConfig expect(source).toContain('hostname: \'example.com\'') expect(source).toContain('handshakeTimeoutMs: 10000') expect(source).toContain('alertDaysBeforeExpiry: 20') - expect(source).toContain('degradedResponseTimeMs: 3000') - expect(source).toContain('maxResponseTimeMs: 10000') - // No top-level response-time fields for SSL. - expect(source).not.toContain('degradedResponseTime:') - expect(source).not.toContain('maxResponseTime:') + // degradedResponseTime/maxResponseTime are now top-level props in the new shape + expect(source).toContain('degradedResponseTime: 3000') + expect(source).toContain('maxResponseTime: 10000') + // Must NOT use the old wire-format names + expect(source).not.toContain('degradedResponseTimeMs:') + expect(source).not.toContain('maxResponseTimeMs:') }) it('emits assertions through SslAssertionBuilder', async () => { diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts index 04857854e..902425030 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts @@ -61,16 +61,16 @@ describe('backend-style export snippets', () => { frequency: 1, locations: ['us-east-1'], tags: ['p5-parity'], + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { + hostname: 'example.com', + port: 443, + ipFamily: 'IPv4', sslConfig: { - hostname: 'example.com', - port: 443, - ipFamily: 'IPv4', skipChainValidation: false, handshakeTimeoutMs: 10000, alertDaysBeforeExpiry: 20, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, }, }) diff --git a/packages/cli/src/constructs/ssl-monitor-codegen.ts b/packages/cli/src/constructs/ssl-monitor-codegen.ts index cd9e39989..7c98f7c30 100644 --- a/packages/cli/src/constructs/ssl-monitor-codegen.ts +++ b/packages/cli/src/constructs/ssl-monitor-codegen.ts @@ -1,12 +1,38 @@ import { Codegen, Context } from './internal/codegen/index.js' import { expr, ident } from '../sourcegen/index.js' import { buildMonitorProps, MonitorResource } from './monitor-codegen.js' -import { SslRequest } from './ssl-request.js' import { valueForSslRequest } from './ssl-request-codegen.js' +import { SslRequest } from './ssl-request.js' +import { SslAssertion } from './ssl-assertion.js' +import { IPFamily } from './ip.js' +import { SecurityBaseline, SslClientCertificateMode } from './ssl-request.js' + +/** + * Wire-format shape of the SSL request as returned by the Checkly API. + * hostname/port/ipFamily/degradedResponseTimeMs/maxResponseTimeMs live inside + * sslConfig on the wire; the construct exposes them at different levels. + */ +export interface SslMonitorWireRequest { + sslConfig: { + hostname: string + port?: number + ipFamily?: IPFamily + serverName?: string + skipChainValidation?: boolean + handshakeTimeoutMs?: number + alertDaysBeforeExpiry?: number + clientCertificateMode?: SslClientCertificateMode + securityBaseline?: SecurityBaseline + degradedResponseTimeMs?: number + maxResponseTimeMs?: number + } + sslClientCertificateId?: string + assertions?: Array +} export interface SslMonitorResource extends MonitorResource { checkType: 'SSL' - request: SslRequest + request: SslMonitorWireRequest } const construct = 'SslMonitor' @@ -26,13 +52,39 @@ export class SslMonitorCodegen extends Codegen { file.namedImport(construct, 'checkly/constructs') + // Map wire format to new construct shape + const wireReq = resource.request + const constructRequest: SslRequest = { + hostname: wireReq.sslConfig.hostname, + port: wireReq.sslConfig.port, + ipFamily: wireReq.sslConfig.ipFamily, + sslConfig: { + serverName: wireReq.sslConfig.serverName, + sslClientCertificateId: wireReq.sslClientCertificateId, + skipChainValidation: wireReq.sslConfig.skipChainValidation, + handshakeTimeoutMs: wireReq.sslConfig.handshakeTimeoutMs, + alertDaysBeforeExpiry: wireReq.sslConfig.alertDaysBeforeExpiry, + clientCertificateMode: wireReq.sslConfig.clientCertificateMode, + securityBaseline: wireReq.sslConfig.securityBaseline, + }, + assertions: wireReq.assertions, + } + file.section(expr(ident(construct), builder => { builder.new(builder => { builder.string(logicalId) builder.object(builder => { buildMonitorProps(this.program, file, builder, resource, context) - builder.value('request', valueForSslRequest(this.program, file, context, resource.request)) + if (wireReq.sslConfig.degradedResponseTimeMs !== undefined) { + builder.number('degradedResponseTime', wireReq.sslConfig.degradedResponseTimeMs) + } + + if (wireReq.sslConfig.maxResponseTimeMs !== undefined) { + builder.number('maxResponseTime', wireReq.sslConfig.maxResponseTimeMs) + } + + builder.value('request', valueForSslRequest(this.program, file, context, constructRequest)) }) }) })) diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 7321323cb..794352969 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -7,12 +7,28 @@ import { InvalidPropertyValueDiagnostic, RequiredPropertyDiagnostic } from './co export interface SslMonitorProps extends MonitorProps { /** * Determines the request that the monitor is going to run. - * - * Unlike most monitors, the response-time limits for an SSL monitor live - * inside the request as `sslConfig.degradedResponseTimeMs` and - * `sslConfig.maxResponseTimeMs`. */ request: SslRequest + + /** + * The handshake time in milliseconds above which the monitor is considered + * degraded. + * + * @minimum 0 + * @maximum 30000 + * @default 3000 + */ + degradedResponseTime?: number + + /** + * The handshake time in milliseconds above which the monitor is considered + * failing. Must be greater than or equal to `degradedResponseTime`. + * + * @minimum 0 + * @maximum 30000 + * @default 10000 + */ + maxResponseTime?: number } /** @@ -20,6 +36,8 @@ export interface SslMonitorProps extends MonitorProps { */ export class SslMonitor extends Monitor { request: SslRequest + degradedResponseTime?: number + maxResponseTime?: number /** * Constructs the SSL Monitor instance @@ -34,6 +52,8 @@ export class SslMonitor extends Monitor { super(logicalId, props) this.request = props.request + this.degradedResponseTime = props.degradedResponseTime + this.maxResponseTime = props.maxResponseTime Session.registerConstruct(this) this.addSubscriptions() @@ -49,7 +69,7 @@ export class SslMonitor extends Monitor { const config = this.request.sslConfig - if (this.request.sslClientCertificateId === undefined && config?.clientCertificateMode === 'explicit') { + if (config?.sslClientCertificateId === undefined && config?.clientCertificateMode === 'explicit') { diagnostics.add(new RequiredPropertyDiagnostic( 'sslClientCertificateId', new Error( @@ -59,14 +79,14 @@ export class SslMonitor extends Monitor { } if ( - config?.degradedResponseTimeMs !== undefined - && config?.maxResponseTimeMs !== undefined - && config.degradedResponseTimeMs > config.maxResponseTimeMs + this.degradedResponseTime !== undefined + && this.maxResponseTime !== undefined + && this.degradedResponseTime > this.maxResponseTime ) { diagnostics.add(new InvalidPropertyValueDiagnostic( - 'degradedResponseTimeMs', + 'degradedResponseTime', new Error( - `The value of "degradedResponseTimeMs" must be less than or equal to "maxResponseTimeMs".`, + `The value of "degradedResponseTime" must be less than or equal to "maxResponseTime".`, ), )) } @@ -76,7 +96,23 @@ export class SslMonitor extends Monitor { return { ...super.synthesize(), checkType: 'SSL', - request: this.request, + request: { + sslConfig: { + hostname: this.request.hostname, + port: this.request.port, + ipFamily: this.request.ipFamily, + serverName: this.request.sslConfig.serverName, + skipChainValidation: this.request.sslConfig.skipChainValidation, + handshakeTimeoutMs: this.request.sslConfig.handshakeTimeoutMs, + alertDaysBeforeExpiry: this.request.sslConfig.alertDaysBeforeExpiry, + clientCertificateMode: this.request.sslConfig.clientCertificateMode, + securityBaseline: this.request.sslConfig.securityBaseline, + degradedResponseTimeMs: this.degradedResponseTime, + maxResponseTimeMs: this.maxResponseTime, + }, + sslClientCertificateId: this.request.sslConfig.sslClientCertificateId, + assertions: this.request.assertions, + }, } } } diff --git a/packages/cli/src/constructs/ssl-request-codegen.ts b/packages/cli/src/constructs/ssl-request-codegen.ts index 4ca3e07b1..ee98f2d91 100644 --- a/packages/cli/src/constructs/ssl-request-codegen.ts +++ b/packages/cli/src/constructs/ssl-request-codegen.ts @@ -10,20 +10,24 @@ export function valueForSslRequest ( request: SslRequest, ): Value { return object(builder => { - const config = request.sslConfig - builder.object('sslConfig', builder => { - builder.string('hostname', config.hostname) + builder.string('hostname', request.hostname) - if (config.port !== undefined && config.port !== 443) { - builder.number('port', config.port) - } + if (request.port !== undefined && request.port !== 443) { + builder.number('port', request.port) + } + if (request.ipFamily && request.ipFamily !== 'IPv4') { + builder.string('ipFamily', request.ipFamily) + } + + const config = request.sslConfig + builder.object('sslConfig', builder => { if (config.serverName) { builder.string('serverName', config.serverName) } - if (config.ipFamily && config.ipFamily !== 'IPv4') { - builder.string('ipFamily', config.ipFamily) + if (config.sslClientCertificateId) { + builder.string('sslClientCertificateId', config.sslClientCertificateId) } if (config.skipChainValidation) { @@ -42,23 +46,11 @@ export function valueForSslRequest ( builder.string('clientCertificateMode', config.clientCertificateMode) } - if (config.degradedResponseTimeMs !== undefined) { - builder.number('degradedResponseTimeMs', config.degradedResponseTimeMs) - } - - if (config.maxResponseTimeMs !== undefined) { - builder.number('maxResponseTimeMs', config.maxResponseTimeMs) - } - if (config.securityBaseline) { builder.value('securityBaseline', unknown(config.securityBaseline)) } }) - if (request.sslClientCertificateId) { - builder.string('sslClientCertificateId', request.sslClientCertificateId) - } - if (request.assertions) { const assertions = request.assertions if (assertions.length > 0) { diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts index c3c01db44..03b094321 100644 --- a/packages/cli/src/constructs/ssl-request.ts +++ b/packages/cli/src/constructs/ssl-request.ts @@ -73,23 +73,6 @@ export type SslClientCertificateMode = 'auto' | 'explicit' * SSL-specific configuration nested inside an SSL monitor's request. */ export interface SslConfig { - /** - * The hostname to connect to and validate the TLS certificate of. Do not - * include a scheme or a port in this value. - * - * @example "example.com" - */ - hostname: string - - /** - * The port number to connect to. - * - * @minimum 1 - * @maximum 65535 - * @default 443 - */ - port?: number - /** * An optional SNI server name to send in the TLS handshake. Defaults to * `hostname` when unset. @@ -97,11 +80,10 @@ export interface SslConfig { serverName?: string /** - * The IP family to use when executing the check. - * - * @default "IPv4" + * The ID of the stored client certificate to present. Required when + * `clientCertificateMode` is `explicit`. */ - ipFamily?: IPFamily + sslClientCertificateId?: string /** * When true, the certificate chain is not validated against trusted roots @@ -130,11 +112,6 @@ export interface SslConfig { */ alertDaysBeforeExpiry?: number - /** - * The SSL security baseline. Omit to inherit the account default baseline. - */ - securityBaseline?: SecurityBaseline - /** * The mutual-TLS client-certificate mode. Omit to inherit the account default * (no certificate sent). @@ -142,24 +119,9 @@ export interface SslConfig { clientCertificateMode?: SslClientCertificateMode /** - * The handshake time in milliseconds above which the monitor is considered - * degraded. - * - * @minimum 0 - * @maximum 30000 - * @default 3000 - */ - degradedResponseTimeMs?: number - - /** - * The handshake time in milliseconds above which the monitor is considered - * failing. Must be greater than or equal to `degradedResponseTimeMs`. - * - * @minimum 0 - * @maximum 30000 - * @default 10000 + * The SSL security baseline. Omit to inherit the account default baseline. */ - maxResponseTimeMs?: number + securityBaseline?: SecurityBaseline } /** @@ -168,15 +130,33 @@ export interface SslConfig { */ export interface SslRequest { /** - * The SSL-specific configuration for the connection. + * The hostname to connect to and validate the TLS certificate of. Do not + * include a scheme or a port in this value. + * + * @example "example.com" */ - sslConfig: SslConfig + hostname: string /** - * The ID of the stored client certificate to present. Required when - * `sslConfig.clientCertificateMode` is `explicit`. + * The port number to connect to. + * + * @minimum 1 + * @maximum 65535 + * @default 443 */ - sslClientCertificateId?: string + port?: number + + /** + * The IP family to use when executing the check. + * + * @default "IPv4" + */ + ipFamily?: IPFamily + + /** + * The SSL-specific configuration for the connection. + */ + sslConfig: SslConfig /** * Assertions to validate the TLS certificate. From 1bd67bb29dcd1f32a942a0e2611bd4ed59c757d4 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Thu, 2 Jul 2026 17:07:39 +0200 Subject: [PATCH 09/10] refactor(cli): drop Ms suffix on SslConfig.handshakeTimeout per review @sorccu: we don't suffix timeout properties with Ms even when the value is milliseconds. Renames the construct field handshakeTimeoutMs -> handshakeTimeout; synthesize() and the codegen wire interface keep the API's handshakeTimeoutMs. --- packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts | 2 +- .../src/constructs/__tests__/uptime-monitor-codegen.spec.ts | 2 +- .../__tests__/uptime-monitor-export-snippets.spec.ts | 2 +- packages/cli/src/constructs/ssl-monitor-codegen.ts | 2 +- packages/cli/src/constructs/ssl-monitor.ts | 2 +- packages/cli/src/constructs/ssl-request-codegen.ts | 4 ++-- packages/cli/src/constructs/ssl-request.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index cea5843b2..9e11449f4 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -42,7 +42,7 @@ describe('SslMonitor', () => { ipFamily: 'IPv4', sslConfig: { skipChainValidation: false, - handshakeTimeoutMs: 10000, + handshakeTimeout: 10000, alertDaysBeforeExpiry: 20, }, assertions: [ diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 2c062fae4..6272b3525 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -157,7 +157,7 @@ describe('SslMonitorCodegen', () => { expect(source).toContain('sslConfig: {') // hostname/port are now top-level in request, not in sslConfig expect(source).toContain('hostname: \'example.com\'') - expect(source).toContain('handshakeTimeoutMs: 10000') + expect(source).toContain('handshakeTimeout: 10000') expect(source).toContain('alertDaysBeforeExpiry: 20') // degradedResponseTime/maxResponseTime are now top-level props in the new shape expect(source).toContain('degradedResponseTime: 3000') diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts index 902425030..c7fa3625a 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts @@ -69,7 +69,7 @@ describe('backend-style export snippets', () => { ipFamily: 'IPv4', sslConfig: { skipChainValidation: false, - handshakeTimeoutMs: 10000, + handshakeTimeout: 10000, alertDaysBeforeExpiry: 20, }, }, diff --git a/packages/cli/src/constructs/ssl-monitor-codegen.ts b/packages/cli/src/constructs/ssl-monitor-codegen.ts index 7c98f7c30..d4ef1950f 100644 --- a/packages/cli/src/constructs/ssl-monitor-codegen.ts +++ b/packages/cli/src/constructs/ssl-monitor-codegen.ts @@ -62,7 +62,7 @@ export class SslMonitorCodegen extends Codegen { serverName: wireReq.sslConfig.serverName, sslClientCertificateId: wireReq.sslClientCertificateId, skipChainValidation: wireReq.sslConfig.skipChainValidation, - handshakeTimeoutMs: wireReq.sslConfig.handshakeTimeoutMs, + handshakeTimeout: wireReq.sslConfig.handshakeTimeoutMs, alertDaysBeforeExpiry: wireReq.sslConfig.alertDaysBeforeExpiry, clientCertificateMode: wireReq.sslConfig.clientCertificateMode, securityBaseline: wireReq.sslConfig.securityBaseline, diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index 794352969..f48767f2e 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -103,7 +103,7 @@ export class SslMonitor extends Monitor { ipFamily: this.request.ipFamily, serverName: this.request.sslConfig.serverName, skipChainValidation: this.request.sslConfig.skipChainValidation, - handshakeTimeoutMs: this.request.sslConfig.handshakeTimeoutMs, + handshakeTimeoutMs: this.request.sslConfig.handshakeTimeout, alertDaysBeforeExpiry: this.request.sslConfig.alertDaysBeforeExpiry, clientCertificateMode: this.request.sslConfig.clientCertificateMode, securityBaseline: this.request.sslConfig.securityBaseline, diff --git a/packages/cli/src/constructs/ssl-request-codegen.ts b/packages/cli/src/constructs/ssl-request-codegen.ts index ee98f2d91..7af651a3d 100644 --- a/packages/cli/src/constructs/ssl-request-codegen.ts +++ b/packages/cli/src/constructs/ssl-request-codegen.ts @@ -34,8 +34,8 @@ export function valueForSslRequest ( builder.boolean('skipChainValidation', config.skipChainValidation) } - if (config.handshakeTimeoutMs !== undefined) { - builder.number('handshakeTimeoutMs', config.handshakeTimeoutMs) + if (config.handshakeTimeout !== undefined) { + builder.number('handshakeTimeout', config.handshakeTimeout) } if (config.alertDaysBeforeExpiry !== undefined) { diff --git a/packages/cli/src/constructs/ssl-request.ts b/packages/cli/src/constructs/ssl-request.ts index 03b094321..e9ca3791f 100644 --- a/packages/cli/src/constructs/ssl-request.ts +++ b/packages/cli/src/constructs/ssl-request.ts @@ -101,7 +101,7 @@ export interface SslConfig { * @maximum 30000 * @default 10000 */ - handshakeTimeoutMs?: number + handshakeTimeout?: number /** * Raise an alert when the certificate is within this many days of expiry. From f0357bb22e54425454f72e05427277cd5b617695 Mon Sep 17 00:00:00 2001 From: Daniel Paulus Date: Fri, 3 Jul 2026 14:35:22 +0200 Subject: [PATCH 10/10] refactor(cli): emit SSL degraded/maxResponseTime at top level to match gRPC/traceroute wire --- .../constructs/__tests__/ssl-monitor.spec.ts | 16 +++++++------- .../__tests__/uptime-monitor-codegen.spec.ts | 4 ++-- .../uptime-monitor-export-snippets.spec.ts | 3 ++- .../cli/src/constructs/ssl-monitor-codegen.ts | 21 ++++++++++--------- packages/cli/src/constructs/ssl-monitor.ts | 4 ++-- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts index 9e11449f4..d574d438d 100644 --- a/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts +++ b/packages/cli/src/constructs/__tests__/ssl-monitor.spec.ts @@ -30,7 +30,7 @@ describe('SslMonitor', () => { expect(check).toMatchObject({ tags: ['default tags'] }) }) - it('should synthesize the SSL check type with top-level response times mapped to wire sslConfig', () => { + it('should synthesize the SSL check type with top-level response times', () => { setupProject() const check = new SslMonitor('test-check', { name: 'Test Check', @@ -55,12 +55,12 @@ describe('SslMonitor', () => { const payload = check.synthesize() as any expect(payload).toMatchObject({ checkType: 'SSL', + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { sslConfig: { hostname: 'example.com', port: 443, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, assertions: [ expect.objectContaining({ source: 'CERT_EXPIRES_IN_DAYS', comparison: 'GREATER_THAN', target: '20' }), @@ -68,9 +68,9 @@ describe('SslMonitor', () => { ], }, }) - // Response times are top-level on the construct but mapped to wire sslConfig. - expect(payload.degradedResponseTime).toBeUndefined() - expect(payload.maxResponseTime).toBeUndefined() + // Response times are top-level, no longer nested inside request.sslConfig. + expect(payload.request.sslConfig.degradedResponseTimeMs).toBeUndefined() + expect(payload.request.sslConfig.maxResponseTimeMs).toBeUndefined() }) it('should synthesize the correct wire shape with hostname and ipFamily inside sslConfig', () => { @@ -98,8 +98,8 @@ describe('SslMonitor', () => { const payload = check.synthesize() as any expect(payload.request.sslConfig.hostname).toBe('example.com') expect(payload.request.sslConfig.ipFamily).toBe('IPv6') - expect(payload.request.sslConfig.degradedResponseTimeMs).toBe(3000) - expect(payload.request.sslConfig.maxResponseTimeMs).toBe(10000) + expect(payload.degradedResponseTime).toBe(3000) + expect(payload.maxResponseTime).toBe(10000) expect(payload.request.sslClientCertificateId).toBe('clientcert_1234') }) diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts index 6272b3525..9a8044281 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-codegen.spec.ts @@ -134,6 +134,8 @@ describe('SslMonitorCodegen', () => { id: 'ssl-check', checkType: 'SSL', name: 'SSL Monitor', + degradedResponseTime: 3000, + maxResponseTime: 10000, request: { sslConfig: { hostname: 'example.com', @@ -142,8 +144,6 @@ describe('SslMonitorCodegen', () => { skipChainValidation: false, handshakeTimeoutMs: 10000, alertDaysBeforeExpiry: 20, - degradedResponseTimeMs: 3000, - maxResponseTimeMs: 10000, }, }, ...overrides, diff --git a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts index c7fa3625a..2f0e1648b 100644 --- a/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts +++ b/packages/cli/src/constructs/__tests__/uptime-monitor-export-snippets.spec.ts @@ -77,8 +77,9 @@ describe('backend-style export snippets', () => { expect(monitor.synthesize()).toMatchObject({ checkType: 'SSL', + maxResponseTime: 10000, request: { - sslConfig: { hostname: 'example.com', port: 443, maxResponseTimeMs: 10000 }, + sslConfig: { hostname: 'example.com', port: 443 }, }, }) }) diff --git a/packages/cli/src/constructs/ssl-monitor-codegen.ts b/packages/cli/src/constructs/ssl-monitor-codegen.ts index d4ef1950f..876aae0ff 100644 --- a/packages/cli/src/constructs/ssl-monitor-codegen.ts +++ b/packages/cli/src/constructs/ssl-monitor-codegen.ts @@ -9,8 +9,9 @@ import { SecurityBaseline, SslClientCertificateMode } from './ssl-request.js' /** * Wire-format shape of the SSL request as returned by the Checkly API. - * hostname/port/ipFamily/degradedResponseTimeMs/maxResponseTimeMs live inside - * sslConfig on the wire; the construct exposes them at different levels. + * hostname/port/ipFamily live inside sslConfig on the wire; the construct + * exposes them at different levels. degradedResponseTime/maxResponseTime + * live at the resource top level, not inside sslConfig. */ export interface SslMonitorWireRequest { sslConfig: { @@ -23,8 +24,6 @@ export interface SslMonitorWireRequest { alertDaysBeforeExpiry?: number clientCertificateMode?: SslClientCertificateMode securityBaseline?: SecurityBaseline - degradedResponseTimeMs?: number - maxResponseTimeMs?: number } sslClientCertificateId?: string assertions?: Array @@ -33,6 +32,8 @@ export interface SslMonitorWireRequest { export interface SslMonitorResource extends MonitorResource { checkType: 'SSL' request: SslMonitorWireRequest + degradedResponseTime?: number + maxResponseTime?: number } const construct = 'SslMonitor' @@ -74,16 +75,16 @@ export class SslMonitorCodegen extends Codegen { builder.new(builder => { builder.string(logicalId) builder.object(builder => { - buildMonitorProps(this.program, file, builder, resource, context) - - if (wireReq.sslConfig.degradedResponseTimeMs !== undefined) { - builder.number('degradedResponseTime', wireReq.sslConfig.degradedResponseTimeMs) + if (resource.degradedResponseTime !== undefined) { + builder.number('degradedResponseTime', resource.degradedResponseTime) } - if (wireReq.sslConfig.maxResponseTimeMs !== undefined) { - builder.number('maxResponseTime', wireReq.sslConfig.maxResponseTimeMs) + if (resource.maxResponseTime !== undefined) { + builder.number('maxResponseTime', resource.maxResponseTime) } + buildMonitorProps(this.program, file, builder, resource, context) + builder.value('request', valueForSslRequest(this.program, file, context, constructRequest)) }) }) diff --git a/packages/cli/src/constructs/ssl-monitor.ts b/packages/cli/src/constructs/ssl-monitor.ts index f48767f2e..38b4c6321 100644 --- a/packages/cli/src/constructs/ssl-monitor.ts +++ b/packages/cli/src/constructs/ssl-monitor.ts @@ -107,12 +107,12 @@ export class SslMonitor extends Monitor { alertDaysBeforeExpiry: this.request.sslConfig.alertDaysBeforeExpiry, clientCertificateMode: this.request.sslConfig.clientCertificateMode, securityBaseline: this.request.sslConfig.securityBaseline, - degradedResponseTimeMs: this.degradedResponseTime, - maxResponseTimeMs: this.maxResponseTime, }, sslClientCertificateId: this.request.sslConfig.sslClientCertificateId, assertions: this.request.assertions, }, + degradedResponseTime: this.degradedResponseTime, + maxResponseTime: this.maxResponseTime, } } }