Skip to content

Commit df80080

Browse files
authored
feat: add assertion and connection error reporters to TCP checks [sc-23082] (#1014)
* feat: add fixtures for various failing TCP checks - no tests yet * feat: add assertion and connection error reporters to TCP checks
1 parent 3e31cf8 commit df80080

File tree

3 files changed

+239
-0
lines changed

3 files changed

+239
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const config = {
2+
projectName: 'TCP Check Failures',
3+
logicalId: process.env.PROJECT_LOGICAL_ID,
4+
repoUrl: 'https://github.com/checkly/checkly-cli',
5+
}
6+
export default config
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/* eslint-disable no-new */
2+
import { TcpCheck, TcpAssertionBuilder } from 'checkly/constructs'
3+
4+
new TcpCheck('tcp-check-dns-failure-ipv4', {
5+
name: 'TCP check with DNS lookup failure (IPv4)',
6+
activated: true,
7+
request: {
8+
hostname: 'does-not-exist.checklyhq.com',
9+
port: 443,
10+
},
11+
})
12+
13+
new TcpCheck('tcp-check-dns-failure-ipv6', {
14+
name: 'TCP check with DNS lookup failure (IPv6)',
15+
activated: true,
16+
request: {
17+
hostname: 'does-not-exist.checklyhq.com',
18+
port: 443,
19+
ipFamily: 'IPv6',
20+
},
21+
})
22+
23+
new TcpCheck('tcp-check-connection-refused', {
24+
name: 'TCP check for connection that gets refused',
25+
activated: true,
26+
request: {
27+
hostname: '127.0.0.1',
28+
port: 12345,
29+
},
30+
})
31+
32+
new TcpCheck('tcp-check-connection-refused-2', {
33+
name: 'TCP check for connection that gets refused #2',
34+
activated: true,
35+
request: {
36+
hostname: '0.0.0.0',
37+
port: 12345,
38+
},
39+
})
40+
41+
new TcpCheck('tcp-check-timed-out', {
42+
name: 'TCP check for connection that times out',
43+
activated: true,
44+
request: {
45+
hostname: 'api.checklyhq.com',
46+
port: 9999,
47+
},
48+
})
49+
50+
new TcpCheck('tcp-check-failing-assertions', {
51+
name: 'TCP check with failing assertions',
52+
activated: true,
53+
request: {
54+
hostname: 'api.checklyhq.com',
55+
port: 80,
56+
data: 'GET / HTTP/1.1\r\nHost: api.checklyhq.com\r\nConnection: close\r\n\r\n',
57+
assertions: [
58+
TcpAssertionBuilder.responseData().contains('NEVER_PRESENT'),
59+
TcpAssertionBuilder.responseTime().lessThan(1),
60+
],
61+
},
62+
})
63+
64+
new TcpCheck('tcp-check-wrong-ip-family', {
65+
name: 'TCP check with wrong IP family',
66+
activated: true,
67+
request: {
68+
hostname: 'ipv4.google.com',
69+
port: 80,
70+
ipFamily: 'IPv6',
71+
},
72+
})

packages/cli/src/reporters/util.ts

+161
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ export function formatCheckResult (checkResult: any) {
104104
])
105105
}
106106
}
107+
} if (checkResult.checkType === 'TCP') {
108+
if (checkResult.checkRunData?.requestError) {
109+
result.push([
110+
formatSectionTitle('Request Error'),
111+
checkResult.checkRunData.requestError,
112+
])
113+
} else {
114+
if (checkResult.checkRunData?.response?.error) {
115+
result.push([
116+
formatSectionTitle('Connection Error'),
117+
formatConnectionError(checkResult.checkRunData?.response?.error),
118+
])
119+
}
120+
if (checkResult.checkRunData?.assertions?.length) {
121+
result.push([
122+
formatSectionTitle('Assertions'),
123+
formatAssertions(checkResult.checkRunData.assertions),
124+
])
125+
}
126+
}
107127
}
108128
if (checkResult.logs?.length) {
109129
result.push([
@@ -132,6 +152,7 @@ const assertionSources: any = {
132152
HEADERS: 'headers',
133153
TEXT_BODY: 'text body',
134154
RESPONSE_TIME: 'response time',
155+
RESPONSE_DATA: 'response data',
135156
}
136157

137158
const assertionComparisons: any = {
@@ -216,6 +237,146 @@ function formatHttpResponse (response: any) {
216237
].filter(Boolean).join('\n')
217238
}
218239

240+
// IPv4 lookup for a non-existing hostname:
241+
//
242+
// {
243+
// "code": "ENOTFOUND",
244+
// "syscall": "queryA",
245+
// "hostname": "does-not-exist.checklyhq.com"
246+
// }
247+
//
248+
// IPv6 lookup for a non-existing hostname:
249+
//
250+
// {
251+
// "code": "ENOTFOUND",
252+
// "syscall": "queryAaaa",
253+
// "hostname": "does-not-exist.checklyhq.com"
254+
// }
255+
interface DNSLookupFailureError {
256+
code: 'ENOTFOUND'
257+
syscall: string
258+
hostname: string
259+
}
260+
261+
function isDNSLookupFailureError (error: any): error is DNSLookupFailureError {
262+
return error.code === 'ENOTFOUND' &&
263+
typeof error.syscall === 'string' &&
264+
typeof error.hostname === 'string'
265+
}
266+
267+
// Connection attempt to a port that isn't open:
268+
//
269+
// {
270+
// "errno": -111,
271+
// "code": "ECONNREFUSED",
272+
// "syscall": "connect",
273+
// "address": "127.0.0.1",
274+
// "port": 22
275+
// }
276+
//
277+
interface ConnectionRefusedError {
278+
code: 'ECONNREFUSED'
279+
errno?: number
280+
syscall: string
281+
address: string
282+
port: number
283+
}
284+
285+
function isConnectionRefusedError (error: any): error is ConnectionRefusedError {
286+
return error.code === 'ECONNREFUSED' &&
287+
typeof error.syscall === 'string' &&
288+
typeof error.address === 'string' &&
289+
typeof error.port === 'number' &&
290+
typeof (error.errno ?? 0) === 'number'
291+
}
292+
293+
// Connection kept open after data exchange and it timed out:
294+
//
295+
// {
296+
// "code": "SOCKET_TIMEOUT",
297+
// "address": "api.checklyhq.com",
298+
// "port": 9999
299+
// }
300+
interface SocketTimeoutError {
301+
code: 'SOCKET_TIMEOUT'
302+
address: string
303+
port: number
304+
}
305+
306+
function isSocketTimeoutError (error: any): error is SocketTimeoutError {
307+
return error.code === 'SOCKET_TIMEOUT' &&
308+
typeof error.address === 'string' &&
309+
typeof error.port === 'number'
310+
}
311+
312+
// Invalid IP address (e.g. IPv4-only hostname when IPFamily is IPv6)
313+
//
314+
// {
315+
// "code": "ERR_INVALID_IP_ADDRESS",
316+
// }
317+
interface InvalidIPAddressError {
318+
code: 'ERR_INVALID_IP_ADDRESS'
319+
}
320+
321+
function isInvalidIPAddressError (error: any): error is InvalidIPAddressError {
322+
return error.code === 'ERR_INVALID_IP_ADDRESS'
323+
}
324+
325+
function formatConnectionError (error: any) {
326+
if (isDNSLookupFailureError(error)) {
327+
const message = [
328+
logSymbols.error,
329+
`DNS lookup for "${error.hostname}" failed`,
330+
`(syscall: ${error.syscall})`,
331+
].join(' ')
332+
return chalk.red(message)
333+
}
334+
335+
if (isConnectionRefusedError(error)) {
336+
const message = [
337+
logSymbols.error,
338+
`Connection to "${error.address}:${error.port}" was refused`,
339+
`(syscall: ${error.syscall}, errno: ${error.errno ?? '<None>'})`,
340+
].join(' ')
341+
return chalk.red(message)
342+
}
343+
344+
if (isSocketTimeoutError(error)) {
345+
const message = [
346+
logSymbols.error,
347+
`Connection to "${error.address}:${error.port}" timed out (perhaps connection was never closed)`,
348+
].join(' ')
349+
return chalk.red(message)
350+
}
351+
352+
if (isInvalidIPAddressError(error)) {
353+
const message = [
354+
logSymbols.error,
355+
'Invalid IP address (perhaps hostname and IP family do not match)',
356+
].join(' ')
357+
return chalk.red(message)
358+
}
359+
360+
// Some other error we don't have detection for.
361+
if (error.code !== undefined) {
362+
const { code, ...extra } = error
363+
const detailsString = JSON.stringify(extra)
364+
const message = [
365+
logSymbols.error,
366+
`${code} (details: ${detailsString})`,
367+
].join(' ')
368+
return chalk.red(message)
369+
}
370+
371+
// If we don't even have a code, give up and output the whole thing.
372+
const detailsString = JSON.stringify(error)
373+
const message = [
374+
logSymbols.error,
375+
`Error (details: ${detailsString})`,
376+
].join(' ')
377+
return chalk.red(message)
378+
}
379+
219380
function formatLogs (logs: Array<{ level: string, msg: string, time: number }>) {
220381
return logs.flatMap(({ level, msg, time }) => {
221382
const timestamp = DateTime.fromMillis(time).toLocaleString(DateTime.TIME_24_WITH_SECONDS)

0 commit comments

Comments
 (0)