Openstatus
www.openstatus.dev
1import { TRPCError } from "@trpc/server";
2
3import { Events } from "@openstatus/analytics";
4import {
5 deserialize,
6 dnsRecords,
7 headerAssertion,
8 jsonBodyAssertion,
9 recordAssertion,
10 statusAssertion,
11 textBodyAssertion,
12} from "@openstatus/assertions";
13import { and, db, eq } from "@openstatus/db";
14import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema";
15import { monitorRegionSchema } from "@openstatus/db/src/schema/constants";
16import {
17 type httpPayloadSchema,
18 type tpcPayloadSchema,
19 transformHeaders,
20} from "@openstatus/utils";
21import { z } from "zod";
22import { env } from "../env";
23import { createTRPCRouter, protectedProcedure } from "../trpc";
24
25const ABORT_TIMEOUT = 10000;
26
27// Input schemas
28const httpTestInput = z.object({
29 url: z.url(),
30 method: z
31 .enum([
32 "GET",
33 "HEAD",
34 "OPTIONS",
35 "POST",
36 "PUT",
37 "DELETE",
38 "PATCH",
39 "CONNECT",
40 "TRACE",
41 ])
42 .prefault("GET"),
43 headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(),
44 body: z.string().optional(),
45 region: monitorRegionSchema.optional().prefault("ams"),
46 assertions: z
47 .array(
48 z.discriminatedUnion("type", [
49 statusAssertion,
50 headerAssertion,
51 textBodyAssertion,
52 jsonBodyAssertion,
53 recordAssertion,
54 ]),
55 )
56 .prefault([]),
57});
58
59const tcpTestInput = z.object({
60 url: z.string(),
61 region: monitorRegionSchema.optional().prefault("ams"),
62});
63
64const dnsTestInput = z.object({
65 url: z.string(),
66 region: monitorRegionSchema.optional().prefault("ams"),
67 assertions: z
68 .array(
69 z.discriminatedUnion("type", [
70 recordAssertion,
71 statusAssertion,
72 headerAssertion,
73 textBodyAssertion,
74 jsonBodyAssertion,
75 ]),
76 )
77 .prefault([]),
78});
79
80export const tcpOutput = z
81 .object({
82 state: z.literal("success").prefault("success"),
83 type: z.literal("tcp").prefault("tcp"),
84 requestId: z.number().optional(),
85 workspaceId: z.number().optional(),
86 monitorId: z.number().optional(),
87 timestamp: z.number(),
88 timing: z.object({
89 tcpStart: z.number(),
90 tcpDone: z.number(),
91 }),
92 error: z.string().optional(),
93 region: monitorRegionSchema,
94 latency: z.number().optional(),
95 })
96 .or(
97 z.object({
98 state: z.literal("error").prefault("error"),
99 message: z.string(),
100 }),
101 );
102
103export const httpOutput = z
104 .object({
105 state: z.literal("success").prefault("success"),
106 type: z.literal("http").prefault("http"),
107 status: z.number(),
108 latency: z.number(),
109 headers: z.record(z.string(), z.string()),
110 timestamp: z.number(),
111 timing: z.object({
112 dnsStart: z.number(),
113 dnsDone: z.number(),
114 connectStart: z.number(),
115 connectDone: z.number(),
116 tlsHandshakeStart: z.number(),
117 tlsHandshakeDone: z.number(),
118 firstByteStart: z.number(),
119 firstByteDone: z.number(),
120 transferStart: z.number(),
121 transferDone: z.number(),
122 }),
123 body: z.string().optional().nullable(),
124 region: monitorRegionSchema,
125 })
126 .or(
127 z.object({
128 state: z.literal("error").prefault("error"),
129 message: z.string(),
130 }),
131 );
132
133export const dnsOutput = z
134 .object({
135 state: z.literal("success").prefault("success"),
136 type: z.literal("dns").prefault("dns"),
137 records: z
138 .partialRecord(z.enum(dnsRecords), z.array(z.string()))
139 .prefault({}),
140 latency: z.number().optional(),
141 timestamp: z.number(),
142 region: monitorRegionSchema,
143 })
144 .or(
145 z.object({
146 state: z.literal("error").prefault("error"),
147 message: z.string(),
148 }),
149 );
150
151export async function testHttp(input: z.infer<typeof httpTestInput>) {
152 // Reject requests to our own domain to avoid loops
153 if (input.url.includes("openstatus.dev")) {
154 throw new TRPCError({
155 code: "BAD_REQUEST",
156 message: "Self-requests are not allowed",
157 });
158 }
159
160 try {
161 const res = await fetch(
162 `https://openstatus-checker.fly.dev/ping/${input.region}`,
163 {
164 method: "POST",
165 headers: {
166 Authorization: `Basic ${env.CRON_SECRET}`,
167 "Content-Type": "application/json",
168 "fly-prefer-region": input.region,
169 },
170 body: JSON.stringify({
171 url: input.url,
172 method: input.method,
173 headers: input.headers?.reduce(
174 (acc, { key, value }) => {
175 if (!key) return acc;
176 return { ...acc, [key]: value };
177 },
178 {} as Record<string, string>,
179 ),
180 body: input.body,
181 }),
182 signal: AbortSignal.timeout(ABORT_TIMEOUT),
183 },
184 );
185
186 const json = await res.json();
187 const result = httpOutput.safeParse(json);
188
189 if (!result.success) {
190 console.error(
191 `Checker HTTP test failed for ${input.url}:`,
192 result.error.message,
193 );
194 throw new TRPCError({
195 code: "BAD_REQUEST",
196 message:
197 "Checker response is not valid. Please try again. If the problem persists, please contact support.",
198 });
199 }
200
201 if (result.data.state === "error") {
202 throw new TRPCError({
203 code: "BAD_REQUEST",
204 message: result.data.message,
205 });
206 }
207
208 if (result.data.state === "success") {
209 const { body, headers, status } = result.data;
210
211 const assertions = deserialize(JSON.stringify(input.assertions)).map(
212 (assertion) =>
213 assertion.assert({
214 body: body ?? "",
215 header: headers ?? {},
216 status: status,
217 }),
218 );
219
220 if (assertions.some((assertion) => !assertion.success)) {
221 throw new TRPCError({
222 code: "BAD_REQUEST",
223 message: `Assertion error: ${
224 assertions.find((assertion) => !assertion.success)?.message
225 }`,
226 });
227 }
228
229 if (assertions.length === 0 && (status < 200 || status >= 300)) {
230 throw new TRPCError({
231 code: "BAD_REQUEST",
232 message: `Assertion error: The response status was not 2XX: ${status}.`,
233 });
234 }
235 }
236
237 return result.data;
238 } catch (error) {
239 console.error("Checker HTTP test failed", error);
240 if (error instanceof TRPCError) {
241 throw error;
242 }
243
244 throw new TRPCError({
245 code: "INTERNAL_SERVER_ERROR",
246 message: error instanceof Error ? error.message : "HTTP check failed",
247 });
248 }
249}
250
251export async function testTcp(input: z.infer<typeof tcpTestInput>) {
252 try {
253 const res = await fetch(
254 `https://openstatus-checker.fly.dev/tcp/${input.region}`,
255 {
256 method: "POST",
257 headers: {
258 Authorization: `Basic ${env.CRON_SECRET}`,
259 "Content-Type": "application/json",
260 "fly-prefer-region": input.region,
261 },
262 body: JSON.stringify({ uri: input.url }),
263 signal: AbortSignal.timeout(ABORT_TIMEOUT),
264 },
265 );
266
267 const json = await res.json();
268 const result = tcpOutput.safeParse(json);
269
270 if (!result.success) {
271 console.error(
272 `Checker TCP test failed for ${input.url}:`,
273 result.error.message,
274 );
275 throw new TRPCError({
276 code: "BAD_REQUEST",
277 message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`,
278 });
279 }
280
281 if (result.data.state === "error") {
282 throw new TRPCError({
283 code: "BAD_REQUEST",
284 message: result.data.message,
285 });
286 }
287
288 return result.data;
289 } catch (error) {
290 console.error("Checker TCP test failed", error);
291 if (error instanceof TRPCError) {
292 throw error;
293 }
294
295 throw new TRPCError({
296 code: "INTERNAL_SERVER_ERROR",
297 message: "TCP check failed",
298 });
299 }
300}
301
302export async function testDns(input: z.infer<typeof dnsTestInput>) {
303 try {
304 const res = await fetch(
305 `https://openstatus-checker.fly.dev/dns/${input.region}`,
306 {
307 method: "POST",
308 headers: {
309 Authorization: `Basic ${env.CRON_SECRET}`,
310 "Content-Type": "application/json",
311 "fly-prefer-region": input.region,
312 },
313 body: JSON.stringify({
314 uri: input.url,
315 }),
316 signal: AbortSignal.timeout(ABORT_TIMEOUT),
317 },
318 );
319
320 const json = await res.json();
321 const result = dnsOutput.safeParse(json);
322
323 if (!result.success) {
324 console.error(
325 `Checker DNS test failed for ${input.url}:`,
326 result.error.message,
327 );
328 throw new TRPCError({
329 code: "BAD_REQUEST",
330 message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`,
331 });
332 }
333
334 if (result.data.state === "error") {
335 throw new TRPCError({
336 code: "BAD_REQUEST",
337 message: result.data.message,
338 });
339 }
340
341 if (result.data.state === "success") {
342 const { records } = result.data;
343
344 const assertions = deserialize(JSON.stringify(input.assertions)).map(
345 (assertion) => assertion.assert({ records }),
346 );
347
348 if (assertions.some((assertion) => !assertion.success)) {
349 throw new TRPCError({
350 code: "BAD_REQUEST",
351 message: `Assertion error: ${
352 assertions.find((assertion) => !assertion.success)?.message
353 }`,
354 });
355 }
356 }
357
358 return result.data;
359 } catch (error) {
360 console.error("Checker DNS test failed", error);
361 if (error instanceof TRPCError) {
362 throw error;
363 }
364
365 throw new TRPCError({
366 code: "INTERNAL_SERVER_ERROR",
367 message: "DNS check failed",
368 });
369 }
370}
371
372export async function triggerChecker(
373 input: z.infer<typeof selectMonitorSchema>,
374) {
375 let payload:
376 | z.infer<typeof httpPayloadSchema>
377 | z.infer<typeof tpcPayloadSchema>
378 | null = null;
379
380 if (process.env.NODE_ENV !== "production") {
381 return;
382 }
383
384 const timestamp = Date.now();
385
386 if (input.jobType === "http") {
387 payload = {
388 workspaceId: String(input.workspaceId),
389 monitorId: String(input.id),
390 url: input.url,
391 method: input.method || "GET",
392 cronTimestamp: timestamp,
393 body: input.body,
394 headers: input.headers,
395 status: "active",
396 assertions: input.assertions ? JSON.parse(input.assertions) : null,
397 degradedAfter: input.degradedAfter,
398 timeout: input.timeout,
399 trigger: "cron",
400 otelConfig: input.otelEndpoint
401 ? {
402 endpoint: input.otelEndpoint,
403 headers: transformHeaders(input.otelHeaders),
404 }
405 : undefined,
406 retry: input.retry || 3,
407 followRedirects: input.followRedirects || true,
408 };
409 }
410 if (input.jobType === "tcp") {
411 payload = {
412 workspaceId: String(input.workspaceId),
413 monitorId: String(input.id),
414 uri: input.url,
415 status: "active",
416 assertions: input.assertions ? JSON.parse(input.assertions) : null,
417 cronTimestamp: timestamp,
418 degradedAfter: input.degradedAfter,
419 timeout: input.timeout,
420 trigger: "cron",
421 retry: input.retry || 3,
422 otelConfig: input.otelEndpoint
423 ? {
424 endpoint: input.otelEndpoint,
425 headers: transformHeaders(input.otelHeaders),
426 }
427 : undefined,
428 followRedirects: input.followRedirects || true,
429 };
430 }
431 if (input.jobType === "dns") {
432 payload = {
433 workspaceId: String(input.workspaceId),
434 monitorId: String(input.id),
435 uri: input.url,
436 status: "active",
437 assertions: input.assertions ? JSON.parse(input.assertions) : null,
438 cronTimestamp: timestamp,
439 degradedAfter: input.degradedAfter,
440 timeout: input.timeout,
441 trigger: "cron",
442 retry: input.retry || 3,
443 otelConfig: input.otelEndpoint
444 ? {
445 endpoint: input.otelEndpoint,
446 headers: transformHeaders(input.otelHeaders),
447 }
448 : undefined,
449 followRedirects: input.followRedirects || true,
450 };
451 }
452 const allResult = [];
453
454 for (const region of input.regions) {
455 const res = fetch(generateUrl({ row: input }), {
456 method: "POST",
457 headers: {
458 Authorization: `Basic ${env.CRON_SECRET}`,
459 "Content-Type": "application/json",
460 "fly-prefer-region": region,
461 },
462 body: JSON.stringify(payload),
463 signal: AbortSignal.timeout(ABORT_TIMEOUT),
464 });
465 allResult.push(res);
466 }
467
468 await Promise.allSettled(allResult);
469}
470
471function generateUrl({ row }: { row: z.infer<typeof selectMonitorSchema> }) {
472 switch (row.jobType) {
473 case "http":
474 return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${row.id}`;
475 case "tcp":
476 return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}`;
477 case "dns":
478 return `https://openstatus-checker.fly.dev/checker/dns?monitor_id=${row.id}`;
479 default:
480 throw new Error("Invalid jobType");
481 }
482}
483
484export const checkerRouter = createTRPCRouter({
485 testHttp: protectedProcedure
486 .meta({ track: Events.TestMonitor })
487 .input(httpTestInput)
488 .mutation(async ({ input }) => {
489 return testHttp(input);
490 }),
491
492 testTcp: protectedProcedure
493 .meta({ track: Events.TestMonitor })
494 .input(tcpTestInput)
495 .mutation(async ({ input }) => {
496 return testTcp(input);
497 }),
498 testDns: protectedProcedure
499 .meta({ track: Events.TestMonitor })
500 .input(dnsTestInput)
501 .mutation(async ({ input }) => {
502 return testDns(input);
503 }),
504
505 triggerChecker: protectedProcedure
506 .input(z.object({ id: z.number() }))
507 .mutation(async (opts) => {
508 const m = await db
509 .select()
510 .from(monitor)
511 .where(
512 and(
513 eq(monitor.id, opts.input.id),
514 eq(monitor.workspaceId, opts.ctx.workspace.id),
515 ),
516 )
517 .get();
518 if (!m) {
519 throw new TRPCError({
520 code: "NOT_FOUND",
521 message: "Monitor not found",
522 });
523 }
524 const input = selectMonitorSchema.parse(m);
525
526 return await triggerChecker(input);
527 }),
528});