Let's write an uptime monitor using Unison Cloud
I saw this repository by yorickpeterse where fish and podman were used to create an uptime notifier and thought it was a very cool, readable repository. And fish is a great shell as well, so double fun. It motivated me to try to do something similar with Unison Cloud and document parts of it.
First up: what does it do?
The fish script is a great read, so do check it out beforehand. From a high-level overview it's doing the following:
- check for settings: IP address of the database and the database port.
- looping through a list of hosts, and for each host:
- do a HTTP GET request
- save the timings of the request
- save the timing results to the database
- sleep a bit so we don't bother the same domain too much
- For a random time between 100 and 300 seconds nap some more to catch outliers that can happen with a fixed interval schedule for checking the list of hosts.
We can start small with this!
Where to start?
I usually read and click around the documentation of a library on Share until I find some type that seems useful for my objective, then I go and play around with an implementation. I'll iterate on it until I get something I like, so here I'll do the same.
Since we want to schedule our monitor to run every so often, we need some kind of an abstraction(or data structure) that does that for us, and I think Daemon can be the one for us.
We'll also want a HTTP client, to make HTTP requests, and lucky for us, the Unison team maintains one right here on Share
Show me the code
Let's start small, with a function that pings a single host:
Note that the function returns an Either. So we can do something depending on the ping returning a Failure or an HttpResponse.
We'll keep track of the hosts to ping in a list in memory for now.
Now let's glue it together to get the core of our small service:
Now is a good time to check out Daemon, because pingHosts is the function we would like to run every X seconds.
A daemon on Unison cloud has to return a Unit, and thus if we want to call it every so often, we have to use recursion. The Unit and recursion usage are the key difference between Daemons and other Unison Cloud types: Daemons are non-terminating programs.
pingDaemon = do
go = do
result = pingHosts()
Remote.sleep (Remote.Duration.minutes 5)
go()
go()
()
todo "deploy pingDaemon and ping things"The inner go function contains the business logic here for now. Let's also not forget to sleep for a random interval
pingDaemon = do
go = do
result = pingHosts()
Remote.sleep (Remote.Duration.minutes 5)
go()
go()
()
todo "deploy pingDaemon and ping things"You might have noticed that result isn't being used anywhere. We have a lot of freedom in how we'd handle an Failure and that's a good next step, however since we have the Daemon ready, why not look at deploying our little daemon service first, since Unison makes this easy:
deployDaemon : '{IO, Exception} DaemonHash
deployDaemon = main do
env = Environment.default()
db = named "funitor-db"
log : LinearLog PingResult
log = named db "funitor-log"
storage = PingStorage db log
daemon = named "funitor"
Daemon.deploy daemon env (pingDaemon storage)And that's it for the deploy! If you enter run deployDaemon in ucm you'll get a new service up and running.
You'll know it worked if you see the following:
run deployDaemon
DaemonHash "a-long-hash-here"Doing more useful things
We aren't doing actual useful stuff just yet. We can start adding several features now:
- Record the timings of the request.
- Change the hard coded list of hosts to something more flexible.
- Save the data of each run.
And a nice-to-have would be sending an email on failure.
Timings
One statistic that could be useful is to time how much time a ping took, so let's edit create a new function that does exactly that:
pingTimed : URI -> '{Http, Remote} PingResult
pingTimed uri = do
use Duration -
start = monotonic!
result = ping uri
end = monotonic!
duration = end - start
PingResult result durationA monotonic! call before and after the call suffices for our use cases for now.
Persistence-ing
A big part of why I like using the Cloud option of Unison is because I'm not spending time on writing (de)serializing code or infrastructure code. Because Unison code can be persisted as-is! We can focus on the data structures and logic of our code.
So for saving a PingResult we're looking for some sort of data structure that's amenable to logging. I went around looking for this datatype that could fit this usecase(without dependencies), but couldn't find one, so I did the next best thing: asking around on Discord.
And thus pchiusano gave me my answer, introducing... LinearLog!
type PingStorage
= lib.zetashift_funitor_1_0_2.PingStorage.PingStorage
Database (LinearLog PingResult)With this we have data structure we can save our runs; how you might ask? It's again a function:
saveRun : PingStorage -> PingResult ->{Exception, Storage} ()
saveRun storage pingResult =
db = PingStorage.db storage
log = runs storage
transact db do tx log pingResultNow the refactor for the daemon becomes very satisfying, because we only need to change up the logic in pingDaemon:
If you run the deployDaemon code again in ucm, you'll have a refactored service that pings and persists!
run deployDaemonThat's it...for now?
Building on top of this, gets really cool because now you can tack on a Slackbot that alerts or sends a weekly email containing the weekly statistics.
Whatever it may be, it's nice to be able to do it while focusing on the business logic.