DEV Community

Cover image for Build a type-safe and event-driven Uptime Monitor in TypeScript
Marcus Kohlberg for Encore

Posted on

Build a type-safe and event-driven Uptime Monitor in TypeScript

TL;DR

This guide shows you how to build and deploy a type-safe, event-driven, uptime monitor in TypeScript.

Uptime Monitor Frontend

To get our app up and running in the cloud in just a few minutes, we'll be using Encore — a development platform that automates infrastructure.

🏁 Let's go!

💽 Install Encore

Install the Encore CLI to run your local environment:

  • macOS: brew install encoredev/tap/encore
  • Linux: curl -L https://encore.dev/install.sh | bash
  • Windows: iwr https://encore.dev/install.ps1 | iex

Create your app

Create a new Encore application, using this tutorial project's starting-point branch. This gives you a ready-to-go frontend to use.

encore app create uptime --example=github.com/encoredev/example-app-uptime/tree/starting-point-ts
Enter fullscreen mode Exit fullscreen mode

If this is the first time using Encore, you'll be asked if you want to create a free account. Go ahead and create one as you'll need it later to deploy your app to Encore's free development cloud.

Check that your frontend works:

cd uptime
encore run
Enter fullscreen mode Exit fullscreen mode

Then visit http://localhost:4000/frontend to see the Next.js frontend.

Note: It won't function yet, since we haven't yet built the backend, so let's do just that!

When we're done we'll have a backend with an event-driven architecture, as seen below in the automatically generated diagram, where white boxes are services and black boxes are Pub/Sub topics:

architecture diagram

Create a monitor service

Let's start by creating the functionality to check if a website is currently up or down.

Later we'll store this result in a database so we can detect when the status changes and send alerts.

Create an Encore service named monitor containing a file named ping.ts.

mkdir monitor
touch monitor/ping.ts
Enter fullscreen mode Exit fullscreen mode

Add an Encore API endpoint named ping that takes a URL as input and returns a response indicating whether the site is up or down, by adding the following to ping.ts:

// Service monitor checks if a website is up or down.
import { api } from "encore.dev/api";

export interface PingParams {
  url: string;
}

export interface PingResponse {
  up: boolean;
}

// Ping pings a specific site and determines whether it's up or down right now.
export const ping = api<PingParams, PingResponse>(
  { expose: true, path: "/ping/:url", method: "GET" },
  async ({ url }) => {
    // If the url does not start with "http:" or "https:", default to "https:".
    if (!url.startsWith("http:") && !url.startsWith("https:")) {
      url = "https://" + url;
    }

    try {
      // Make an HTTP request to check if it's up.
      const resp = await fetch(url, { method: "GET" });
      // 2xx and 3xx status codes are considered up
      const up = resp.status >= 200 && resp.status < 300;
      return { up };
    } catch (err) {
      return { up: false };
    }
  }
);

Enter fullscreen mode Exit fullscreen mode

Let's try it! Run encore run in your terminal and you should see the service start up.

Then open up the Local Development Dashboard running at http://localhost:9400 and try calling
the monitor.ping endpoint, passing in google.com as the URL.

If you prefer to use the terminal instead run curl http://localhost:4000/ping/google.com in
a new terminal instead. Either way you should see the response:

{"up": true}
Enter fullscreen mode Exit fullscreen mode

You can also try with httpstat.us/400 and some-non-existing-url.com and it should respond with {"up": false}.
(It's always a good idea to test the negative case as well.)

Add a test

Let's write an automated test so we don't break this endpoint over time. Create the file monitor/ping.test.ts with the content:

import { describe, expect, test } from "vitest";
import { ping } from "./ping";

describe("ping", () => {
  test.each([
    // Test both with and without "https://"
    { site: "google.com", expected: true },
    { site: "https://encore.dev", expected: true },

    // 4xx and 5xx should considered down.
    { site: "https://not-a-real-site.xyz", expected: false },
    // Invalid URLs should be considered down.
    { site: "invalid://scheme", expected: false },
  ])(
    `should verify that $site is ${"$expected" ? "up" : "down"}`,
    async ({ site, expected }) => {
      const resp = await ping({ url: site });
      expect(resp.up).toBe(expected);
    },
  );
});
Enter fullscreen mode Exit fullscreen mode

Run encore test to check that it all works as expected. You should see something like:

$ encore test

DEV  v1.3.0

✓ monitor/ping.test.ts (4)
  ✓ ping (4)
    ✓ should verify that 'google.com' is up
    ✓ should verify that 'https://encore.dev' is up
    ✓ should verify that 'https://not-a-real-site.xyz' is up
    ✓ should verify that 'invalid://scheme' is up

Test Files  1 passed (1)
     Tests  4 passed (4)
  Start at  12:31:03
  Duration  460ms (transform 43ms, setup 0ms, collect 59ms, tests 272ms, environment 0ms, prepare 47ms)

PASS  Waiting for file changes...
Enter fullscreen mode Exit fullscreen mode

Create site service

Next, we want to keep track of a list of websites to monitor.

Since most of these APIs will be simple "CRUD" (Create/Read/Update/Delete) endpoints, let's build this service using Knex.js, an ORM
library that makes building CRUD endpoints really simple.

Let's create a new service named site with a SQL database. To do so, create a new directory site in the application root with migrations folder inside that folder:

$ mkdir site
$ mkdir site/migrations
Enter fullscreen mode Exit fullscreen mode

Add a database migration file inside that folder, named 1_create_tables.up.sql.

The file name is important (it must look something like 1_<name>.up.sql).

Add the following contents:

-- site/migrations/1_create_tables.up.sql --
CREATE TABLE site (
    id SERIAL PRIMARY KEY,
    url TEXT NOT NULL UNIQUE
);
Enter fullscreen mode Exit fullscreen mode

Next, install the Knex.js library and PostgreSQL client:

$ npm i knex pg
Enter fullscreen mode Exit fullscreen mode

Now let's create the site service itself with our CRUD endpoints.

Create site/site.ts with the contents:

import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import knex from "knex";

// Site describes a monitored site.
export interface Site {
  id: number; // ID is a unique ID for the site.
  url: string; // URL is the site's URL.
}

// AddParams are the parameters for adding a site to be monitored.
export interface AddParams {
  // URL is the URL of the site. If it doesn't contain a scheme
  // (like "http:" or "https:") it defaults to "https:".
  url: string;
}

// Add a new site to the list of monitored websites.
export const add = api(
  { expose: true, method: "POST", path: "/site" },
  async (params: AddParams): Promise<Site> => {
    const site = (await Sites().insert({ url: params.url }, "*"))[0];
    return site;
  },
);

// Get a site by id.
export const get = api(
  { expose: true, method: "GET", path: "/site/:id", auth: false },
  async ({ id }: { id: number }): Promise<Site> => {
    const site = await Sites().where("id", id).first();
    return site ?? Promise.reject(new Error("site not found"));
  },
);

// Delete a site by id.
export const del = api(
  { expose: true, method: "DELETE", path: "/site/:id" },
  async ({ id }: { id: number }): Promise<void> => {
    await Sites().where("id", id).delete();
  },
);

export interface ListResponse {
  sites: Site[]; // Sites is the list of monitored sites
}

// Lists the monitored websites.
export const list = api(
  { expose: true, method: "GET", path: "/site" },
  async (): Promise<ListResponse> => {
    const sites = await Sites().select();
    return { sites };
  },
);

// Define a database named 'site', using the database migrations
// in the "./migrations" folder. Encore automatically provisions,
// migrates, and connects to the database.
const SiteDB = new SQLDatabase("site", {
  migrations: "./migrations",
});

const orm = knex({
  client: "pg",
  connection: SiteDB.connectionString,
});

const Sites = () => orm<Site>("site");
Enter fullscreen mode Exit fullscreen mode

Now make sure you have Docker installed and running, and then restart encore run to cause the site database to be created by Encore. Then let's call the site.add endpoint:

$ curl -X POST 'http://localhost:4000/site' -d '{"url": "https://encore.dev"}'
{
  "id": 1,
  "url": "https://encore.dev"
}
Enter fullscreen mode Exit fullscreen mode

Record uptime checks

In order to notify when a website goes down or comes back up, we need to track the previous state it was in.

To do so, let's add a database to the monitor service as well.
Create the directory monitor/migrations and the file monitor/migrations/1_create_tables.up.sql:

CREATE TABLE checks (
    id BIGSERIAL PRIMARY KEY,
    site_id BIGINT NOT NULL,
    up BOOLEAN NOT NULL,
    checked_at TIMESTAMP WITH TIME ZONE NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

We'll insert a database row every time we check if a site is up.

Add a new endpoint check to the monitor service, that
takes in a Site ID, pings the site, and inserts a database row
in the checks table.

For this service we'll use Encore's SQLDatabase class
instead of Knex (in order to showcase both approaches).

Add the following to check.ts:

import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { ping } from "./ping";
import { site } from "~encore/clients";

// Check checks a single site.
export const check = api(
  { expose: true, method: "POST", path: "/check/:siteID" },
  async (p: { siteID: number }): Promise<{ up: boolean }> => {
    const s = await site.get({ id: p.siteID });
    const { up } = await ping({ url: s.url });
    await MonitorDB.exec`
        INSERT INTO checks (site_id, up, checked_at)
        VALUES (${s.id}, ${up}, NOW())
    `;
    return { up };
  },
);

// Define a database named 'monitor', using the database migrations
// in the "./migrations" folder. Encore automatically provisions,
// migrates, and connects to the database.
export const MonitorDB = new SQLDatabase("monitor", {
  migrations: "./migrations",
});
Enter fullscreen mode Exit fullscreen mode

Restart encore run to cause the monitor database to be created, and then call the new monitor.check endpoint:

curl -X POST 'http://localhost:4000/check/1'
Enter fullscreen mode Exit fullscreen mode

Inspect the database to make sure everything worked:

$ encore db shell monitor
psql (14.4, server 14.2)
Type "help" for help.

monitor=> SELECT * FROM checks;
 id | site_id | up |          checked_at
----+---------+----+-------------------------------
  1 |       1 | t  | 2022-10-21 09:58:30.674265+00
Enter fullscreen mode Exit fullscreen mode

If that's what you see, everything's working great! 🥳

Add a cron job to check all sites

We now want to regularly check all the tracked sites so we can
respond in case any of them go down.

We'll create a new checkAll API endpoint in the monitor service
that will list all the tracked sites and check all of them.

Let's extract some of the functionality we wrote for the
check endpoint into a separate function, by changing check.ts like so:

import {Site} from "../site/site";

// Check checks a single site.
export const check = api(
  { expose: true, method: "POST", path: "/check/:siteID" },
  async (p: { siteID: number }): Promise<{ up: boolean }> => {
    const s = await site.get({ id: p.siteID });
    return doCheck(s);
  },
);

async function doCheck(site: Site): Promise<{ up: boolean }> {
  const { up } = await ping({ url: site.url });
  await MonitorDB.exec`
      INSERT INTO checks (site_id, up, checked_at)
      VALUES (${site.id}, ${up}, NOW())
  `;
  return { up };
}
Enter fullscreen mode Exit fullscreen mode

Now we're ready to create our new checkAll endpoint.

Create the new checkAll endpoint inside monitor/check.ts:

// CheckAll checks all sites.
export const checkAll = api(
  { expose: true, method: "POST", path: "/check-all" },
  async (): Promise<void> => {
    const sites = await site.list();
    await Promise.all(sites.sites.map(doCheck));
  },
);
Enter fullscreen mode Exit fullscreen mode

Now that we have a checkAll endpoint, define a cron job to automatically call it every 1 hour (since this is an example, we don't need to go too crazy and check every minute).

Simply add the following to check.ts:

import { CronJob } from "encore.dev/cron";

// Check all tracked sites every 1 hour.
const cronJob = new CronJob("check-all", {
  title: "Check all sites",
  every: "1h",
  endpoint: checkAll,
});
Enter fullscreen mode Exit fullscreen mode

Note: Cron jobs are not triggered when running the application locally but work when deploying the application to a cloud environment.

Create a status endpoint

The frontend needs a way to list all sites and display if they are up or down.

Add a file in the monitor service and name it status.ts. Add the following code:

import { api } from "encore.dev/api";
import { MonitorDB } from "./check";

interface SiteStatus {
  id: number;
  up: boolean;
  checkedAt: string;
}

// StatusResponse is the response type from the Status endpoint.
interface StatusResponse {
  // Sites contains the current status of all sites,
  // keyed by the site ID.
  sites: SiteStatus[];
}

// status checks the current up/down status of all monitored sites.
export const status = api(
  { expose: true, path: "/status", method: "GET" },
  async (): Promise<StatusResponse> => {
    const rows = await MonitorDB.query`
      SELECT DISTINCT ON (site_id) site_id, up, checked_at
      FROM checks
      ORDER BY site_id, checked_at DESC
    `;
    const results: SiteStatus[] = [];
    for await (const row of rows) {
      results.push({
        id: row.site_id,
        up: row.up,
        checkedAt: row.checked_at,
      });
    }
    return { sites: results };
  },
);
Enter fullscreen mode Exit fullscreen mode

Now try visiting http://localhost:4000/ in your browser again. This time you should see a working frontend that lists all sites and their current status.

Deploy to Encore's development cloud

To try out your uptime monitor for real, let's deploy it to Encore's free development cloud.

Encore comes with built-in CI/CD, and the deployment process is as simple as a git push.
(You can also integrate with GitHub if you want, learn more in the docs.)

Now, let's deploy our app to Encore's free development cloud by running:

git add -A .
git commit -m 'Initial commit'
git push encore
Enter fullscreen mode Exit fullscreen mode

Encore will now build and test your app, provision the needed infrastructure, and deploy your application to the cloud.

After triggering the deployment, you will see a URL where you can view its progress in Encore's Cloud Dashboard. It will look something like: https://app.encore.dev/$APP_ID/deploys/...

From there you can also monitor and trigger your Cron Jobs, and see traces for all requests (even for pub/sub):

Cron Jobs

Trace

Later you can also link your app to a GitHub repo to get automatic deploys on new commits, and connect your own AWS or GCP account to use for production deployment.

When the deploy has finished, you can try out your uptime monitor by going to https://staging-$APP_ID.encr.app.

🎉 You now have an Uptime Monitor running in the cloud, well done!

Publish Pub/Sub events when a site goes down

Hold on, we're not done yet!

An uptime monitoring system isn't very useful if it doesn't actually notify you when a site goes down.

To do so let's add a Pub/Sub topic on which we'll publish a message every time a site transitions from being up to being down, or vice versa.

Define the topic using Encore's Pub/Sub module in monitor/check.ts:

import { Subscription, Topic } from "encore.dev/pubsub";

// TransitionEvent describes a transition of a monitored site
// from up->down or from down->up.
export interface TransitionEvent {
  site: Site; // Site is the monitored site in question.
  up: boolean; // Up specifies whether the site is now up or down (the new value).
}

// TransitionTopic is a pubsub topic with transition events for when a monitored site
// transitions from up->down or from down->up.
export const TransitionTopic = new Topic<TransitionEvent>("uptime-transition", {
  deliveryGuarantee: "at-least-once",
});
Enter fullscreen mode Exit fullscreen mode

Now let's publish a message on the TransitionTopic if a site's up/down state differs from the previous measurement.

Create a getPreviousMeasurement function to report the last up/down state in check.ts:

// getPreviousMeasurement reports whether the given site was
// up or down in the previous measurement.
async function getPreviousMeasurement(siteID: number): Promise<boolean> {
  const row = await MonitorDB.queryRow`
      SELECT up
      FROM checks
      WHERE site_id = ${siteID}
      ORDER BY checked_at DESC
      LIMIT 1
  `;
  return row?.up ?? true;
}
Enter fullscreen mode Exit fullscreen mode

Now add a function to conditionally publish a message if the up/down state differs by modifying the doCheck function in check.ts:

async function doCheck(site: Site): Promise<{ up: boolean }> {
  const { up } = await ping({ url: site.url });

  // Publish a Pub/Sub message if the site transitions
  // from up->down or from down->up.
  const wasUp = await getPreviousMeasurement(site.id);
  if (up !== wasUp) {
    await TransitionTopic.publish({ site, up });
  }

  await MonitorDB.exec`
      INSERT INTO checks (site_id, up, checked_at)
      VALUES (${site.id}, ${up}, NOW())
  `;
  return { up };
}
Enter fullscreen mode Exit fullscreen mode

Now the monitoring system will publish messages on the TransitionTopic whenever a monitored site transitions from up->down or from down->up.

However, it doesn't know or care who actually listens to these messages.

The truth is right now nobody does. So let's fix that by adding
a Pub/Sub subscriber that posts these events to Slack.

Send Slack notifications when a site goes down

Start by creating a Slack service slack/slack.ts containing the following:

import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import log from "encore.dev/log";

export interface NotifyParams {
  text: string; // the slack message to send
}

// Sends a Slack message to a pre-configured channel using a
// Slack Incoming Webhook (see https://api.slack.com/messaging/webhooks).
export const notify = api<NotifyParams>({}, async ({ text }) => {
  const url = webhookURL();
  if (!url) {
    log.info("no slack webhook url defined, skipping slack notification");
    return;
  }

  const resp = await fetch(url, {
    method: "POST",
    body: JSON.stringify({ text }),
  });
  if (resp.status >= 400) {
    const body = await resp.text();
    throw new Error(`slack notification failed: ${resp.status}: ${body}`);
  }
});

// SlackWebhookURL defines the Slack webhook URL to send uptime notifications to.
const webhookURL = secret("SlackWebhookURL");
Enter fullscreen mode Exit fullscreen mode

Now go to a Slack community of your choice where you have the permission to create a new Incoming Webhook.

Once you have the Webhook URL, we can use Encore's built-in secrets manager to store it securely:

encore secret set --type dev,local,pr SlackWebhookURL
Enter fullscreen mode Exit fullscreen mode

Test the slack.notify endpoint by calling it via cURL:

curl 'http://localhost:4000/slack.notify' -d '{"text": "Testing Slack webhook"}'
Enter fullscreen mode Exit fullscreen mode

You should see the Testing Slack webhook message appear in the Slack channel you designated for the webhook.

When it works it's time to add a Pub/Sub subscriber to automatically notify Slack when a monitored site goes up or down. Add the following to slack/slack.ts:

import { Subscription } from "encore.dev/pubsub";
import { TransitionTopic } from "../monitor/check";

const _ = new Subscription(TransitionTopic, "slack-notification", {
  handler: async (event) => {
    const text = `*${event.site.url} is ${event.up ? "back up." : "down!"}*`;
    await notify({ text });
  },
});
Enter fullscreen mode Exit fullscreen mode

🚀 Deploy your finished Uptime Monitor

Now you're ready to deploy your finished Uptime Monitor, complete with a Slack integration.

As before, deploying your app to the cloud is as simple as running:

git add -A .
git commit -m 'Add slack integration'
git push encore
Enter fullscreen mode Exit fullscreen mode

🎉 You're done!

You've now built a fully functioning uptime monitoring system and deployed it to the cloud.

It's pretty remarkable how much you've accomplished in such little code:

  • You've built three different services (site, monitor, and slack)
  • You've added two databases (to the site and monitor services) for tracking monitored sites and the monitoring results
  • You've added a cron job for automatically checking the sites every hour
  • You've set up a Pub/Sub topic to decouple the monitoring system from the Slack notifications
  • You've added a Slack integration, using secrets to securely store the webhook URL, listening to a Pub/Sub subscription for up/down transition events

All of this in just a bit over 300 lines of code!

What's next

Top comments (0)