From 87fba3b09582a165996f61f0702317eea967d34a Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 5 Feb 2026 02:33:24 +0900 Subject: [PATCH 1/8] feat: add ICMP monitor support with the new IcmpMonitor construct --- .../constructs/__tests__/icmp-monitor.spec.ts | 273 ++++++++++++++++++ packages/cli/src/constructs/check-codegen.ts | 8 + .../src/constructs/icmp-assertion-codegen.ts | 21 ++ packages/cli/src/constructs/icmp-assertion.ts | 66 +++++ .../src/constructs/icmp-monitor-codegen.ts | 50 ++++ packages/cli/src/constructs/icmp-monitor.ts | 87 ++++++ .../src/constructs/icmp-request-codegen.ts | 34 +++ packages/cli/src/constructs/icmp-request.ts | 41 +++ packages/cli/src/constructs/index.ts | 3 + .../constructs/internal/assertion-codegen.ts | 10 +- .../cli/src/constructs/internal/assertion.ts | 8 +- 11 files changed, 597 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/icmp-monitor.spec.ts create mode 100644 packages/cli/src/constructs/icmp-assertion-codegen.ts create mode 100644 packages/cli/src/constructs/icmp-assertion.ts create mode 100644 packages/cli/src/constructs/icmp-monitor-codegen.ts create mode 100644 packages/cli/src/constructs/icmp-monitor.ts create mode 100644 packages/cli/src/constructs/icmp-request-codegen.ts create mode 100644 packages/cli/src/constructs/icmp-request.ts diff --git a/packages/cli/src/constructs/__tests__/icmp-monitor.spec.ts b/packages/cli/src/constructs/__tests__/icmp-monitor.spec.ts new file mode 100644 index 000000000..5573dc941 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/icmp-monitor.spec.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from 'vitest' + +import { IcmpMonitor, CheckGroup, IcmpRequest, IcmpAssertionBuilder, Diagnostics } from '../index' +import { Project, Session } from '../project' + +const request: IcmpRequest = { + hostname: 'acme.com', +} + +describe('IcmpMonitor', () => { + it('should apply default check settings', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.checkDefaults = { tags: ['default tags'] } + const check = new IcmpMonitor('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', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + Session.checkDefaults = { tags: ['default tags'] } + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + tags: ['test check'], + request, + }) + Session.checkDefaults = undefined + expect(check).toMatchObject({ tags: ['test check'] }) + }) + + it('should support setting groups with `groupId`', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new IcmpMonitor('main-check', { + name: 'Main Check', + request, + groupId: group.ref(), + }) + const bundle = await check.bundle() + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + it('should support setting groups with `group`', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const group = new CheckGroup('main-group', { name: 'Main Group', locations: [] }) + const check = new IcmpMonitor('main-check', { + name: 'Main Check', + request, + group, + }) + const bundle = await check.bundle() + expect(bundle.synthesize()).toMatchObject({ groupId: { ref: 'main-group' } }) + }) + + it('should synthesize with correct checkType', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + request, + degradedPacketLossThreshold: 10, + maxPacketLossThreshold: 20, + }) + const synthesized = check.synthesize() + expect(synthesized).toMatchObject({ + checkType: 'ICMP', + request: { hostname: 'acme.com' }, + degradedPacketLossThreshold: 10, + maxPacketLossThreshold: 20, + }) + }) + + it('should support ipFamily and pingCount in request', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + ipFamily: 'IPv6', + pingCount: 5, + }, + }) + const synthesized = check.synthesize() + expect(synthesized.request).toMatchObject({ + hostname: 'example.com', + ipFamily: 'IPv6', + pingCount: 5, + }) + }) + + describe('validation', () => { + it('should error if doubleCheck is set', async () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + request, + // @ts-expect-error Not available in the type. + doubleCheck: true, + }) + + const diags = new Diagnostics() + await check.validate(diags) + + expect(diags.isFatal()).toEqual(true) + expect(diags.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('Property "doubleCheck" is not supported.'), + }), + ])) + }) + }) + + describe('assertions', () => { + it('should support latency assertions with property', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + assertions: [ + IcmpAssertionBuilder.latency('avg').lessThan(100), + IcmpAssertionBuilder.latency('max').lessThan(200), + IcmpAssertionBuilder.latency('min').greaterThan(10), + IcmpAssertionBuilder.latency('stdDev').lessThan(50), + ], + }, + }) + + const synthesized = check.synthesize() + expect(synthesized.request.assertions).toEqual([ + { source: 'LATENCY', property: 'avg', comparison: 'LESS_THAN', target: '100', regex: null }, + { source: 'LATENCY', property: 'max', comparison: 'LESS_THAN', target: '200', regex: null }, + { source: 'LATENCY', property: 'min', comparison: 'GREATER_THAN', target: '10', regex: null }, + { source: 'LATENCY', property: 'stdDev', comparison: 'LESS_THAN', target: '50', regex: null }, + ]) + }) + + it('should support JSON response assertions', () => { + Session.project = new Project('project-id', { + name: 'Test Project', + repoUrl: 'https://github.com/checkly/checkly-cli', + }) + + const check = new IcmpMonitor('test-check', { + name: 'Test Check', + request: { + hostname: 'example.com', + assertions: [ + IcmpAssertionBuilder.jsonResponse('$.packetLoss').lessThan(10), + IcmpAssertionBuilder.jsonResponse('$.packetsReceived').greaterThan(8), + ], + }, + }) + + const synthesized = check.synthesize() + expect(synthesized.request.assertions).toEqual([ + { source: 'JSON_RESPONSE', property: '$.packetLoss', comparison: 'LESS_THAN', target: '10', regex: null }, + { source: 'JSON_RESPONSE', property: '$.packetsReceived', comparison: 'GREATER_THAN', target: '8', regex: null }, + ]) + }) + }) +}) + +describe('IcmpAssertionBuilder', () => { + describe('latency', () => { + it('should create equals assertion', () => { + const assertion = IcmpAssertionBuilder.latency('avg').equals(50) + expect(assertion).toEqual({ + source: 'LATENCY', + property: 'avg', + comparison: 'EQUALS', + target: '50', + regex: null, + }) + }) + + it('should create notEquals assertion', () => { + const assertion = IcmpAssertionBuilder.latency('avg').notEquals(50) + expect(assertion).toEqual({ + source: 'LATENCY', + property: 'avg', + comparison: 'NOT_EQUALS', + target: '50', + regex: null, + }) + }) + + it('should create lessThan assertion', () => { + const assertion = IcmpAssertionBuilder.latency('max').lessThan(100) + expect(assertion).toEqual({ + source: 'LATENCY', + property: 'max', + comparison: 'LESS_THAN', + target: '100', + regex: null, + }) + }) + + it('should create greaterThan assertion', () => { + const assertion = IcmpAssertionBuilder.latency('min').greaterThan(10) + expect(assertion).toEqual({ + source: 'LATENCY', + property: 'min', + comparison: 'GREATER_THAN', + target: '10', + regex: null, + }) + }) + }) + + describe('jsonResponse', () => { + it('should create assertion with property', () => { + const assertion = IcmpAssertionBuilder.jsonResponse('$.packetLoss').lessThan(10) + expect(assertion).toEqual({ + source: 'JSON_RESPONSE', + property: '$.packetLoss', + comparison: 'LESS_THAN', + target: '10', + regex: null, + }) + }) + + it('should create assertion without property', () => { + const assertion = IcmpAssertionBuilder.jsonResponse().notEmpty() + expect(assertion).toEqual({ + source: 'JSON_RESPONSE', + property: '', + comparison: 'NOT_EMPTY', + target: '', + regex: null, + }) + }) + + it('should support contains assertion', () => { + const assertion = IcmpAssertionBuilder.jsonResponse('$.hostname').contains('example') + expect(assertion).toEqual({ + source: 'JSON_RESPONSE', + property: '$.hostname', + comparison: 'CONTAINS', + target: 'example', + regex: null, + }) + }) + }) +}) diff --git a/packages/cli/src/constructs/check-codegen.ts b/packages/cli/src/constructs/check-codegen.ts index 878cfe775..905470095 100644 --- a/packages/cli/src/constructs/check-codegen.ts +++ b/packages/cli/src/constructs/check-codegen.ts @@ -15,6 +15,7 @@ import { UrlMonitorCodegen, UrlMonitorResource } from './url-monitor-codegen' import { valueForPrivateLocationFromId } from './private-location-codegen' import { valueForAlertChannelFromId } from './alert-channel-codegen' import { DnsMonitorCodegen, DnsMonitorResource } from './dns-monitor-codegen' +import { IcmpMonitorCodegen, IcmpMonitorResource } from './icmp-monitor-codegen' export interface CheckResource { id: string @@ -216,6 +217,7 @@ export class CheckCodegen extends Codegen { tcpMonitorCodegen: TcpMonitorCodegen urlMonitorCodegen: UrlMonitorCodegen dnsMonitorCodegen: DnsMonitorCodegen + icmpMonitorCodegen: IcmpMonitorCodegen constructor (program: Program) { super(program) @@ -227,6 +229,7 @@ export class CheckCodegen extends Codegen { this.tcpMonitorCodegen = new TcpMonitorCodegen(program) this.urlMonitorCodegen = new UrlMonitorCodegen(program) this.dnsMonitorCodegen = new DnsMonitorCodegen(program) + this.icmpMonitorCodegen = new IcmpMonitorCodegen(program) } describe (resource: CheckResource): string { @@ -247,6 +250,8 @@ export class CheckCodegen extends Codegen { return this.urlMonitorCodegen.describe(resource as UrlMonitorResource) case 'DNS': return this.dnsMonitorCodegen.describe(resource as DnsMonitorResource) + case 'ICMP': + return this.icmpMonitorCodegen.describe(resource as IcmpMonitorResource) default: throw new Error(`Unable to describe unsupported check type '${checkType}'.`) } @@ -277,6 +282,9 @@ export class CheckCodegen extends Codegen { case 'DNS': this.dnsMonitorCodegen.gencode(logicalId, resource as DnsMonitorResource, context) return + case 'ICMP': + this.icmpMonitorCodegen.gencode(logicalId, resource as IcmpMonitorResource, context) + return default: throw new Error(`Unable to generate code for unsupported check type '${checkType}'.`) } diff --git a/packages/cli/src/constructs/icmp-assertion-codegen.ts b/packages/cli/src/constructs/icmp-assertion-codegen.ts new file mode 100644 index 000000000..3e9e4495b --- /dev/null +++ b/packages/cli/src/constructs/icmp-assertion-codegen.ts @@ -0,0 +1,21 @@ +import { GeneratedFile, Value } from '../sourcegen' +import { valueForGeneralAssertion, valueForNumericAssertion } from './internal/assertion-codegen' +import { IcmpAssertion } from './icmp-assertion' + +export function valueForIcmpAssertion (genfile: GeneratedFile, assertion: IcmpAssertion): Value { + genfile.namedImport('IcmpAssertionBuilder', 'checkly/constructs') + + switch (assertion.source) { + case 'LATENCY': + return valueForNumericAssertion('IcmpAssertionBuilder', 'latency', assertion, { + hasProperty: true, + }) + case 'JSON_RESPONSE': + return valueForGeneralAssertion('IcmpAssertionBuilder', 'jsonResponse', assertion, { + hasProperty: true, + hasRegex: false, + }) + default: + throw new Error(`Unsupported ICMP assertion source ${assertion.source}`) + } +} diff --git a/packages/cli/src/constructs/icmp-assertion.ts b/packages/cli/src/constructs/icmp-assertion.ts new file mode 100644 index 000000000..d5a417673 --- /dev/null +++ b/packages/cli/src/constructs/icmp-assertion.ts @@ -0,0 +1,66 @@ +import { Assertion as CoreAssertion, NumericAssertionBuilder, GeneralAssertionBuilder } from './internal/assertion' + +type IcmpAssertionSource = + | 'LATENCY' + | 'JSON_RESPONSE' + +export type IcmpAssertion = CoreAssertion + +export type IcmpLatencyProperty = 'avg' | 'min' | 'max' | 'stdDev' + +/** + * Builder class for creating ICMP monitor assertions. + * Provides methods to create assertions for ICMP ping responses. + * + * @example + * ```typescript + * // Latency assertions + * IcmpAssertionBuilder.latency('avg').lessThan(100) + * IcmpAssertionBuilder.latency('max').lessThan(200) + * IcmpAssertionBuilder.latency('stdDev').lessThan(50) + * + * // JSON response assertions + * IcmpAssertionBuilder.jsonResponse('$.packetLoss').lessThan(10) + * IcmpAssertionBuilder.jsonResponse('$.packetsReceived').greaterThan(8) + * ``` + */ +export class IcmpAssertionBuilder { + /** + * Creates an assertion builder for ICMP latency metrics. + * @param property The latency property to assert against: + * - `avg`: Average round-trip time in milliseconds + * - `min`: Minimum round-trip time in milliseconds + * - `max`: Maximum round-trip time in milliseconds + * - `stdDev`: Standard deviation of round-trip times + * @returns A numeric assertion builder for the specified latency metric. + */ + static latency (property: IcmpLatencyProperty) { + return new NumericAssertionBuilder('LATENCY', property) + } + + /** + * Creates an assertion builder for the JSON formatted response. + * Use JSONPath expressions to access specific response fields. + * + * Available fields include: + * - `$.hostname`: The target host that was pinged + * - `$.resolvedIp`: The resolved IP address (if host was a domain name) + * - `$.ipFamily`: IP family used ('IPv4' or 'IPv6') + * - `$.pingCount`: Number of pings configured + * - `$.packetsSent`: Number of ICMP echo requests sent + * - `$.packetsReceived`: Number of ICMP echo replies received + * - `$.packetLoss`: Percentage of packets lost (0-100) + * - `$.latency.avg`: Average round-trip time in milliseconds + * - `$.latency.min`: Minimum round-trip time in milliseconds + * - `$.latency.max`: Maximum round-trip time in milliseconds + * - `$.latency.stdDev`: Standard deviation of round-trip times + * - `$.pingResults[*].rtt`: Round-trip time for each ping + * - `$.pingResults[*].ttl`: Time-to-live for each ping + * + * @param property Optional JSONPath to specific property (e.g., '$.packetLoss') + * @returns A general assertion builder for the JSON formatted response. + */ + static jsonResponse (property?: string) { + return new GeneralAssertionBuilder('JSON_RESPONSE', property) + } +} diff --git a/packages/cli/src/constructs/icmp-monitor-codegen.ts b/packages/cli/src/constructs/icmp-monitor-codegen.ts new file mode 100644 index 000000000..85e4f4f35 --- /dev/null +++ b/packages/cli/src/constructs/icmp-monitor-codegen.ts @@ -0,0 +1,50 @@ +import { Codegen, Context } from './internal/codegen' +import { expr, ident } from '../sourcegen' +import { buildMonitorProps, MonitorResource } from './monitor-codegen' +import { IcmpRequest } from './icmp-request' +import { valueForIcmpRequest } from './icmp-request-codegen' + +export interface IcmpMonitorResource extends MonitorResource { + checkType: 'ICMP' + request: IcmpRequest + degradedPacketLossThreshold?: number + maxPacketLossThreshold?: number +} + +const construct = 'IcmpMonitor' + +export class IcmpMonitorCodegen extends Codegen { + describe (resource: IcmpMonitorResource): string { + return `ICMP Monitor: ${resource.name}` + } + + gencode (logicalId: string, resource: IcmpMonitorResource, context: Context): void { + const filePath = context.filePath('resources/icmp-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 => { + builder.value('request', valueForIcmpRequest(this.program, file, context, resource.request)) + + if (resource.degradedPacketLossThreshold !== undefined) { + builder.number('degradedPacketLossThreshold', resource.degradedPacketLossThreshold) + } + + if (resource.maxPacketLossThreshold !== undefined) { + builder.number('maxPacketLossThreshold', resource.maxPacketLossThreshold) + } + + buildMonitorProps(this.program, file, builder, resource, context) + }) + }) + })) + } +} diff --git a/packages/cli/src/constructs/icmp-monitor.ts b/packages/cli/src/constructs/icmp-monitor.ts new file mode 100644 index 000000000..34850c104 --- /dev/null +++ b/packages/cli/src/constructs/icmp-monitor.ts @@ -0,0 +1,87 @@ +import { Monitor, MonitorProps } from './monitor' +import { Session } from './project' +import { Diagnostics } from './diagnostics' +import { IcmpRequest } from './icmp-request' + +export interface IcmpMonitorProps extends MonitorProps { + /** + * Determines the request that the monitor is going to run. + */ + request: IcmpRequest + + /** + * The percentage of packet loss where the monitor should be considered + * degraded. + * + * @defaultValue 10 + * @minimum 0 + * @maximum 100 + * @example + * ```typescript + * degradedPacketLossThreshold: 10 // Alert when 10% or more packets are lost + * ``` + */ + degradedPacketLossThreshold?: number + + /** + * The percentage of packet loss where the monitor should be considered + * failing. + * + * @defaultValue 20 + * @minimum 0 + * @maximum 100 + * @example + * ```typescript + * maxPacketLossThreshold: 20 // Fail if 20% or more packets are lost + * ``` + */ + maxPacketLossThreshold?: number +} + +/** + * Creates an ICMP Monitor + */ +export class IcmpMonitor extends Monitor { + request: IcmpRequest + degradedPacketLossThreshold?: number + maxPacketLossThreshold?: number + + /** + * Constructs the ICMP Monitor instance + * + * @param logicalId unique project-scoped resource name identification + * @param props configuration properties + * + * {@link https://www.checklyhq.com/docs/constructs/icmp-monitor/ Read more in the docs} + */ + + constructor (logicalId: string, props: IcmpMonitorProps) { + super(logicalId, props) + + this.request = props.request + this.degradedPacketLossThreshold = props.degradedPacketLossThreshold + this.maxPacketLossThreshold = props.maxPacketLossThreshold + + Session.registerConstruct(this) + this.addSubscriptions() + this.addPrivateLocationCheckAssignments() + } + + describe (): string { + return `IcmpMonitor:${this.logicalId}` + } + + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + } + + synthesize () { + return { + ...super.synthesize(), + checkType: 'ICMP', + request: this.request, + degradedPacketLossThreshold: this.degradedPacketLossThreshold, + maxPacketLossThreshold: this.maxPacketLossThreshold, + } + } +} diff --git a/packages/cli/src/constructs/icmp-request-codegen.ts b/packages/cli/src/constructs/icmp-request-codegen.ts new file mode 100644 index 000000000..bb616b437 --- /dev/null +++ b/packages/cli/src/constructs/icmp-request-codegen.ts @@ -0,0 +1,34 @@ +import { GeneratedFile, object, Program, Value } from '../sourcegen' +import { valueForIcmpAssertion } from './icmp-assertion-codegen' +import { IcmpRequest } from './icmp-request' +import { Context } from './internal/codegen' + +export function valueForIcmpRequest ( + program: Program, + genfile: GeneratedFile, + context: Context, + request: IcmpRequest, +): Value { + return object(builder => { + builder.string('hostname', request.hostname) + + if (request.ipFamily && request.ipFamily !== 'IPv4') { + builder.string('ipFamily', request.ipFamily) + } + + if (request.pingCount !== undefined) { + builder.number('pingCount', request.pingCount) + } + + if (request.assertions) { + const assertions = request.assertions + if (assertions.length > 0) { + builder.array('assertions', builder => { + for (const assertion of assertions) { + builder.value(valueForIcmpAssertion(genfile, assertion)) + } + }) + } + } + }) +} diff --git a/packages/cli/src/constructs/icmp-request.ts b/packages/cli/src/constructs/icmp-request.ts new file mode 100644 index 000000000..236b2b9e6 --- /dev/null +++ b/packages/cli/src/constructs/icmp-request.ts @@ -0,0 +1,41 @@ +import { IcmpAssertion } from './icmp-assertion' +import { IPFamily } from './ip' + +/** + * Configuration for ICMP ping requests. + * Defines the target host and ping parameters. + */ +export interface IcmpRequest { + /** + * The domain name or IP address to ping. + * + * @example "www.example.com" + * @example "199.43.133.53" + */ + hostname: string + + /** + * The IP protocol version to use for pinging. + * + * @example "IPv4" + * @example "IPv6" + * @default "IPv4" + */ + ipFamily?: IPFamily + + /** + * The number of ICMP echo requests (pings) to send. This allows you to see + * and assert how many packets were received. + * + * @minimum 1 + * @maximum 100 + * @example 10 + * @default 10 + */ + pingCount?: number + + /** + * Assertions to validate the ICMP response. + */ + assertions?: Array +} diff --git a/packages/cli/src/constructs/index.ts b/packages/cli/src/constructs/index.ts index 54e6616f2..914ffe95c 100644 --- a/packages/cli/src/constructs/index.ts +++ b/packages/cli/src/constructs/index.ts @@ -45,3 +45,6 @@ export * from './ip' export * from './dns-monitor' export * from './dns-assertion' export * from './dns-request' +export * from './icmp-monitor' +export * from './icmp-assertion' +export * from './icmp-request' diff --git a/packages/cli/src/constructs/internal/assertion-codegen.ts b/packages/cli/src/constructs/internal/assertion-codegen.ts index a47d20184..0d6170334 100644 --- a/packages/cli/src/constructs/internal/assertion-codegen.ts +++ b/packages/cli/src/constructs/internal/assertion-codegen.ts @@ -1,15 +1,23 @@ import { expr, ident, Value } from '../../sourcegen' import { Assertion } from './assertion' +export interface ValueForNumericAssertionOptions { + hasProperty?: boolean +} + export function valueForNumericAssertion ( klass: string, method: string, assertion: Assertion, + options?: ValueForNumericAssertionOptions, ): Value { return expr(ident(klass), builder => { builder.member(ident(method)) builder.call(builder => { - builder.empty() + const hasProperty = options?.hasProperty ?? false + if (hasProperty && assertion.property !== '') { + builder.string(assertion.property) + } }) switch (assertion.comparison) { case 'EQUALS': diff --git a/packages/cli/src/constructs/internal/assertion.ts b/packages/cli/src/constructs/internal/assertion.ts index efb0f12ff..26d1c8a30 100644 --- a/packages/cli/src/constructs/internal/assertion.ts +++ b/packages/cli/src/constructs/internal/assertion.ts @@ -22,11 +22,13 @@ export interface Assertion { regex: string | null } -export class NumericAssertionBuilder { +export class NumericAssertionBuilder { source: Source + property?: Property - constructor (source: Source) { + constructor (source: Source, property?: Property) { this.source = source + this.property = property } equals (target: number): Assertion { @@ -50,7 +52,7 @@ export class NumericAssertionBuilder { return { source: this.source, comparison, - property: '', + property: this.property ?? '', target: target.toString(), regex: null, } From b3ea71cf81d478dec7e42c34a62f41b1a4266147 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 5 Feb 2026 02:46:04 +0900 Subject: [PATCH 2/8] feat: add examples --- .../src/__checks__/uptime/icmp.check.js | 22 +++++++++++++++++++ .../src/__checks__/uptime/icmp.check.ts | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 examples/advanced-project-js/src/__checks__/uptime/icmp.check.js create mode 100644 examples/advanced-project/src/__checks__/uptime/icmp.check.ts diff --git a/examples/advanced-project-js/src/__checks__/uptime/icmp.check.js b/examples/advanced-project-js/src/__checks__/uptime/icmp.check.js new file mode 100644 index 000000000..5cf1b9909 --- /dev/null +++ b/examples/advanced-project-js/src/__checks__/uptime/icmp.check.js @@ -0,0 +1,22 @@ +const { IcmpMonitor, IcmpAssertionBuilder } = require('checkly/constructs') +const { uptimeGroup } = require('../utils/website-groups.check') + +// ICMP monitors check if a host is reachable by sending ICMP echo requests (pings). +// They're useful for monitoring network connectivity and measuring latency to hosts. +// Read more: https://www.checklyhq.com/docs/icmp-monitors/ + +new IcmpMonitor('cloudflare-dns-icmp', { + name: 'Cloudflare DNS ICMP Monitor', + activated: true, + group: uptimeGroup, + maxPacketLossThreshold: 20, // percentage + degradedPacketLossThreshold: 10, + request: { + hostname: '1.1.1.1', + pingCount: 10, + assertions: [ + IcmpAssertionBuilder.latency('avg').lessThan(100), + IcmpAssertionBuilder.latency('max').lessThan(200), + ] + } +}) diff --git a/examples/advanced-project/src/__checks__/uptime/icmp.check.ts b/examples/advanced-project/src/__checks__/uptime/icmp.check.ts new file mode 100644 index 000000000..03f6f0b86 --- /dev/null +++ b/examples/advanced-project/src/__checks__/uptime/icmp.check.ts @@ -0,0 +1,22 @@ +import { IcmpMonitor, IcmpAssertionBuilder } from 'checkly/constructs' +import { uptimeGroup } from '../utils/website-groups.check' + +// ICMP monitors check if a host is reachable by sending ICMP echo requests (pings). +// They're useful for monitoring network connectivity and measuring latency to hosts. +// Read more: https://www.checklyhq.com/docs/icmp-monitors/ + +new IcmpMonitor('cloudflare-dns-icmp', { + name: 'Cloudflare DNS ICMP Monitor', + activated: true, + group: uptimeGroup, + maxPacketLossThreshold: 20, // percentage + degradedPacketLossThreshold: 10, + request: { + hostname: '1.1.1.1', + pingCount: 10, + assertions: [ + IcmpAssertionBuilder.latency('avg').lessThan(100), + IcmpAssertionBuilder.latency('max').lessThan(200), + ] + } +}) From d35cfc697c87fff6c33c0eafdf245a9c8eb5a793 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 5 Feb 2026 02:58:05 +0900 Subject: [PATCH 3/8] feat: add IcmpMonitor example to skills --- .../ai-context/checkly.context.template.md | 12 ++- .../cli/src/ai-context/context.fixtures.json | 82 +++++++++++++++++++ packages/cli/src/ai-context/context.ts | 5 ++ skills/monitoring/SKILL.md | 47 ++++++++++- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ai-context/checkly.context.template.md b/packages/cli/src/ai-context/checkly.context.template.md index 6435dcde2..559c7aba1 100644 --- a/packages/cli/src/ai-context/checkly.context.template.md +++ b/packages/cli/src/ai-context/checkly.context.template.md @@ -1,6 +1,6 @@ --- name: monitoring -description: Create and manage monitoring checks using the Checkly CLI. Use when working with API checks, browser checks, URL monitors, Playwright checks, heartbeat monitors, alert channels, dashboards, or status pages. +description: Create and manage monitoring checks using the Checkly CLI. Use when working with API checks, browser checks, URL monitors, ICMP monitors, Playwright checks, heartbeat monitors, alert channels, dashboards, or status pages. allowed-tools: Bash(npx:checkly:*) Bash(npm:create:checkly@latest) metadata: author: checkly @@ -109,6 +109,16 @@ The `checkly.config.ts` at the root of your project defines a range of defaults // INSERT DNS MONITOR EXAMPLE HERE // +### ICMP Monitor + +- Import the `IcmpMonitor` construct from `checkly/constructs`. +- Reference [the docs for ICMP monitors](https://www.checklyhq.com/docs/constructs/icmp-monitor/) before generating any code. +- When adding `assertions`, always use `IcmpAssertionBuilder` class. +- Latency assertions require a property parameter: `'avg'`, `'min'`, `'max'`, or `'stdDev'`. +- Use `degradedPacketLossThreshold` and `maxPacketLossThreshold` for packet loss thresholds (percentages). + +// INSERT ICMP MONITOR EXAMPLE HERE // + ### Heartbeat Monitor - Import the `HeartbeatMonitor` construct from `checkly/constructs`. diff --git a/packages/cli/src/ai-context/context.fixtures.json b/packages/cli/src/ai-context/context.fixtures.json index c4e17b72a..392465462 100644 --- a/packages/cli/src/ai-context/context.fixtures.json +++ b/packages/cli/src/ai-context/context.fixtures.json @@ -789,6 +789,88 @@ "privateLocations": [] } }, + { + "logicalId": "example-icmp-monitor", + "physicalId": "c02bf554-ccd7-4953-8b72-a96c3c91b2a5", + "type": "check", + "member": true, + "pending": true, + "payload": { + "id": "c02bf554-ccd7-4953-8b72-a96c3c91b2a5", + "checkType": "ICMP", + "name": "Example ICMP Monitor", + "frequency": 10, + "frequencyOffset": 91, + "activated": true, + "muted": false, + "shouldFail": false, + "locations": [ + "eu-central-1", + "eu-north-1" + ], + "script": null, + "created_at": "2025-10-28T17:25:17.037Z", + "updated_at": null, + "environmentVariables": [], + "doubleCheck": false, + "tags": [], + "sslCheckDomain": "", + "setupSnippetId": null, + "tearDownSnippetId": null, + "localSetupScript": null, + "localTearDownScript": null, + "alertSettings": { + "reminders": { + "amount": 0, + "interval": 5 + }, + "escalationType": "RUN_BASED", + "runBasedEscalation": { + "failedRunThreshold": 1 + }, + "timeBasedEscalation": { + "minutesFailingThreshold": 5 + }, + "parallelRunFailureThreshold": { + "enabled": false, + "percentage": 10 + } + }, + "useGlobalAlertSettings": true, + "degradedPacketLossThreshold": 10, + "maxPacketLossThreshold": 20, + "groupId": null, + "groupOrder": null, + "runtimeId": null, + "scriptPath": null, + "retryStrategy": null, + "runParallel": false, + "triggerIncident": null, + "request": { + "hostname": "1.1.1.1", + "ipFamily": "IPv4", + "pingCount": 10, + "assertions": [ + { + "regex": null, + "source": "LATENCY", + "target": "100", + "property": "avg", + "comparison": "LESS_THAN" + }, + { + "regex": null, + "source": "LATENCY", + "target": "200", + "property": "max", + "comparison": "LESS_THAN" + } + ] + }, + "alertChannelSubscriptions": [], + "privateLocations": [] + } + }, { "logicalId": "example-maintenance-window", "physicalId": 1000001, diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index 02b653c00..c9f701550 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -97,6 +97,11 @@ const playwrightChecks = new PlaywrightCheck("multi-browser-check", { exampleConfigPath: 'resources/dns-monitors/example-dns-monitor.check.ts', reference: 'https://www.checklyhq.com/docs/constructs/dns-monitor/', }, + ICMP_MONITOR: { + templateString: '// INSERT ICMP MONITOR EXAMPLE HERE //', + exampleConfigPath: 'resources/icmp-monitors/example-icmp-monitor.check.ts', + reference: 'https://www.checklyhq.com/docs/constructs/icmp-monitor/', + }, CHECK_GROUP: { templateString: '// INSERT CHECK GROUP EXAMPLE HERE //', exampleConfigPath: diff --git a/skills/monitoring/SKILL.md b/skills/monitoring/SKILL.md index d1a6c60b3..b85584650 100644 --- a/skills/monitoring/SKILL.md +++ b/skills/monitoring/SKILL.md @@ -1,6 +1,6 @@ --- name: monitoring -description: Create and manage monitoring checks using the Checkly CLI. Use when working with API checks, browser checks, URL monitors, Playwright checks, heartbeat monitors, alert channels, dashboards, or status pages. +description: Create and manage monitoring checks using the Checkly CLI. Use when working with API checks, browser checks, URL monitors, ICMP monitors, Playwright checks, heartbeat monitors, alert channels, dashboards, or status pages. allowed-tools: Bash(npx:checkly:*) Bash(npm:create:checkly@latest) metadata: author: checkly @@ -374,6 +374,51 @@ new DnsMonitor('example-dns-monitor', { }) ``` +### ICMP Monitor + +- Import the `IcmpMonitor` construct from `checkly/constructs`. +- Reference [the docs for ICMP monitors](https://www.checklyhq.com/docs/constructs/icmp-monitor/) before generating any code. +- When adding `assertions`, always use `IcmpAssertionBuilder` class. +- Latency assertions require a property parameter: `'avg'`, `'min'`, `'max'`, or `'stdDev'`. +- Use `degradedPacketLossThreshold` and `maxPacketLossThreshold` for packet loss thresholds (percentages). + +**Reference:** https://www.checklyhq.com/docs/constructs/icmp-monitor/ + +```typescript +import { AlertEscalationBuilder, Frequency, IcmpAssertionBuilder, IcmpMonitor, RetryStrategyBuilder } from 'checkly/constructs' + +new IcmpMonitor('example-icmp-monitor', { + name: 'Example ICMP Monitor', + request: { + hostname: '1.1.1.1', + pingCount: 10, + assertions: [ + IcmpAssertionBuilder.latency('avg').lessThan(100), + IcmpAssertionBuilder.latency('max').lessThan(200), + ], + }, + degradedPacketLossThreshold: 10, + maxPacketLossThreshold: 20, + activated: true, + muted: false, + shouldFail: false, + locations: [ + 'eu-central-1', + 'eu-north-1', + ], + frequency: Frequency.EVERY_10M, + alertEscalationPolicy: AlertEscalationBuilder.runBasedEscalation(1, { + amount: 0, + interval: 5, + }, { + enabled: false, + percentage: 10, + }), + retryStrategy: RetryStrategyBuilder.noRetries(), + runParallel: false, +}) +``` + ### Heartbeat Monitor - Import the `HeartbeatMonitor` construct from `checkly/constructs`. From eab3e276ab1d8ad2dde91406cfdd0e8f9f2d0d5b Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 5 Feb 2026 03:14:41 +0900 Subject: [PATCH 4/8] fix: remove unnecessary properties from the skills fixture Mainly done to remove shouldFail from the generated example. --- packages/cli/src/ai-context/context.fixtures.json | 11 ----------- skills/monitoring/SKILL.md | 1 - 2 files changed, 12 deletions(-) diff --git a/packages/cli/src/ai-context/context.fixtures.json b/packages/cli/src/ai-context/context.fixtures.json index 392465462..c61067ae5 100644 --- a/packages/cli/src/ai-context/context.fixtures.json +++ b/packages/cli/src/ai-context/context.fixtures.json @@ -803,22 +803,13 @@ "frequencyOffset": 91, "activated": true, "muted": false, - "shouldFail": false, "locations": [ "eu-central-1", "eu-north-1" ], - "script": null, "created_at": "2025-10-28T17:25:17.037Z", "updated_at": null, - "environmentVariables": [], - "doubleCheck": false, "tags": [], - "sslCheckDomain": "", - "setupSnippetId": null, - "tearDownSnippetId": null, - "localSetupScript": null, - "localTearDownScript": null, "alertSettings": { "reminders": { "amount": 0, @@ -841,8 +832,6 @@ "maxPacketLossThreshold": 20, "groupId": null, "groupOrder": null, - "runtimeId": null, - "scriptPath": null, "retryStrategy": null, "runParallel": false, "triggerIncident": null, diff --git a/skills/monitoring/SKILL.md b/skills/monitoring/SKILL.md index b85584650..34a5da649 100644 --- a/skills/monitoring/SKILL.md +++ b/skills/monitoring/SKILL.md @@ -401,7 +401,6 @@ new IcmpMonitor('example-icmp-monitor', { maxPacketLossThreshold: 20, activated: true, muted: false, - shouldFail: false, locations: [ 'eu-central-1', 'eu-north-1', From 6377dd397230d102796dd6f5e2e4c2bc8f63829c Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 11 Feb 2026 17:16:50 +0000 Subject: [PATCH 5/8] feat: add reporter formatting for ICMP monitors --- packages/cli/src/reporters/util.ts | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 4da110b46..8471866b2 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -176,6 +176,39 @@ export function formatCheckResult (checkResult: any) { } } } + if (checkResult.checkType === 'ICMP') { + if (checkResult.checkRunData?.requestError) { + result.push([ + formatSectionTitle('Request Error'), + checkResult.checkRunData.requestError, + ]) + } else { + if (checkResult.checkRunData?.request) { + result.push([ + formatSectionTitle('ICMP Request'), + formatIcmpRequest(checkResult.checkRunData.request), + ]) + } + if (checkResult.checkRunData?.response) { + result.push([ + formatSectionTitle('ICMP Response'), + formatIcmpResponse(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([ + formatSectionTitle('Assertions'), + formatAssertions(checkResult.checkRunData.assertions), + ]) + } + } + } if (checkResult.logs?.length) { result.push([ formatSectionTitle('Logs'), @@ -207,6 +240,8 @@ const assertionSources: any = { TEXT_ANSWER: 'answer (text)', JSON_ANSWER: 'answer (JSON)', RESPONSE_CODE: 'response code', + LATENCY: 'latency', + JSON_RESPONSE: 'response data (JSON)', } const assertionComparisons: any = { @@ -429,6 +464,72 @@ function isInvalidIPAddressError (error: any): error is InvalidIPAddressError { return error.code === 'ERR_INVALID_IP_ADDRESS' } +type ICMPRequest = { + hostname: string + targetIp: string + ipFamily: 'IPv4' | 'IPv6' + pingCount: number +} + +function formatIcmpRequest (request: ICMPRequest) { + return [ + `Hostname: ${request.hostname}`, + request.targetIp ? `Target IP: ${formatHostPort(request.targetIp)}` : '', + `IP Family: ${request.ipFamily}`, + `Ping Count: ${request.pingCount}`, + ].filter(Boolean).join('\n') +} + +type ICMPResponse = { + hostname: string + resolvedIp?: string + ipFamily: 'IPv4' | 'IPv6' + pingCount: number + packetsSent: number + packetsReceived: number + packetSize: number + packetLoss: number + dnsResolutionTime: number + latency: { + avg: number + min: number + max: number + stdDev: number + } + pingResults: { + sequence: number + type: number + code: number + success: boolean + rtt: number + ttl: number + }[] +} + +function formatLatency (value: number) { + return `${value.toFixed(3)}ms` +} + +function formatIcmpResponse (response: ICMPResponse) { + return [ + response.resolvedIp ? `Resolved IP: ${response.resolvedIp}` : undefined, + `Packets Sent: ${response.packetsSent}`, + `Packets Received: ${response.packetsReceived}`, + `Latency (min): ${formatLatency(response.latency.min)}`, + `Latency (max): ${formatLatency(response.latency.max)}`, + `Latency (avg): ${formatLatency(response.latency.avg)}`, + `Latency (stdDev): ${formatLatency(response.latency.stdDev)}`, + `Ping Results:`, + response.pingResults.map(result => { + if (result.success) { + return ` ${result.sequence}: ttl=${result.ttl} time=${formatLatency(result.rtt)}` + } else { + return ` ${result.sequence}: N/A` + } + }).join('\n'), + ].filter(Boolean).join('\n') +} + function formatConnectionError (error: any) { if (isDNSLookupFailureError(error)) { const message = [ From f5b39592fbe50ef4650da9ea3061a682b701abf2 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 12 Feb 2026 14:01:02 +0000 Subject: [PATCH 6/8] feat: include IcmpMonitor in e2e tests --- packages/cli/e2e/__tests__/deploy.spec.ts | 16 ++++++---------- .../fixtures/deploy-project/icmp.check.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 packages/cli/e2e/__tests__/fixtures/deploy-project/icmp.check.ts diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index 7ff0ad6fb..d23a179c2 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -145,11 +145,8 @@ describe('deploy', { timeout: 45_000 }, () => { const checkGroups = await getAllResources('check-groups') const privateLocations = await getAllResources('private-locations') - // Check that all assignments were applied - // Filter out heartbeat checks as they don't have the privateLocations property - expect(checks.filter(({ checkType }: { checkType: string }) => checkType !== CheckTypes.HEARTBEAT) - .filter(({ privateLocations }: { privateLocations: string[] }) => - privateLocations.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(1) + expect(checks.filter(({ privateLocations }: { privateLocations?: string[] }) => + privateLocations?.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(1) expect(checkGroups.filter(({ privateLocations }: { privateLocations: string[] }) => privateLocations.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(2) expect(privateLocations @@ -172,11 +169,8 @@ describe('deploy', { timeout: 45_000 }, () => { const checkGroups = await getAllResources('check-groups') const privateLocations = await getAllResources('private-locations') - // Check that all assignments were applied - // Filter out heartbeat checks as they don't have the privateLocations property - expect(checks.filter(({ checkType }: { checkType: string }) => checkType !== CheckTypes.HEARTBEAT) - .filter(({ privateLocations }: { privateLocations: string[] }) => - privateLocations.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(1) + expect(checks.filter(({ privateLocations }: { privateLocations?: string[] }) => + privateLocations?.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(1) expect(checkGroups.filter(({ privateLocations }: { privateLocations: string[] }) => privateLocations.some(slugName => slugName.startsWith(privateLocationSlugname))).length).toEqual(2) expect(privateLocations @@ -211,6 +205,7 @@ describe('deploy', { timeout: 45_000 }, () => { DnsMonitor: dns-welcome-aaaa HeartbeatMonitor: heartbeat-monitor-1 BrowserCheck: homepage-browser-check + IcmpMonitor: icmp-welcome TcpMonitor: tcp-monitor CheckGroupV2: my-group-1 CheckGroupV1: my-group-2-v1 @@ -232,6 +227,7 @@ describe('deploy', { timeout: 45_000 }, () => { DnsMonitor: dns-welcome-aaaa HeartbeatMonitor: heartbeat-monitor-1 BrowserCheck: homepage-browser-check + IcmpMonitor: icmp-welcome BrowserCheck: snapshot-test.test.ts TcpMonitor: tcp-monitor CheckGroupV2: my-group-1 diff --git a/packages/cli/e2e/__tests__/fixtures/deploy-project/icmp.check.ts b/packages/cli/e2e/__tests__/fixtures/deploy-project/icmp.check.ts new file mode 100644 index 000000000..1fc0f808f --- /dev/null +++ b/packages/cli/e2e/__tests__/fixtures/deploy-project/icmp.check.ts @@ -0,0 +1,15 @@ +import { IcmpAssertionBuilder, IcmpMonitor } from 'checkly/constructs' + +new IcmpMonitor('icmp-welcome', { + name: 'Welcome Site Reachability', + activated: false, + request: { + hostname: 'welcome.checklyhq.com', + pingCount: 10, + assertions: [ + IcmpAssertionBuilder.latency('max').lessThan(200), + ], + }, + degradedPacketLossThreshold: 20, + maxPacketLossThreshold: 30, +}) From fba140b6872b2c2dc02ab030e3e1f33137ced700 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 17 Feb 2026 23:17:10 +0900 Subject: [PATCH 7/8] chore: remove unused import --- packages/cli/e2e/__tests__/deploy.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/e2e/__tests__/deploy.spec.ts b/packages/cli/e2e/__tests__/deploy.spec.ts index d23a179c2..d98dac7f9 100644 --- a/packages/cli/e2e/__tests__/deploy.spec.ts +++ b/packages/cli/e2e/__tests__/deploy.spec.ts @@ -8,7 +8,6 @@ import { DateTime, Duration } from 'luxon' import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest' import Projects from '../../src/rest/projects' -import { CheckTypes } from '../../src/constants' import { FixtureSandbox, RunOptions } from '../../src/testing/fixture-sandbox' import { ExecaError } from 'execa' From 339702ac7e45e9d0cd13561ced22f3b6aefc69a2 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 17 Feb 2026 23:21:46 +0900 Subject: [PATCH 8/8] fix: the max value for pingCount is 50, not 100 --- packages/cli/src/constructs/icmp-request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/constructs/icmp-request.ts b/packages/cli/src/constructs/icmp-request.ts index 236b2b9e6..21fad4647 100644 --- a/packages/cli/src/constructs/icmp-request.ts +++ b/packages/cli/src/constructs/icmp-request.ts @@ -28,7 +28,7 @@ export interface IcmpRequest { * and assert how many packets were received. * * @minimum 1 - * @maximum 100 + * @maximum 50 * @example 10 * @default 10 */