FilamentPHP: Shooting lasers at the moon
Photo by Nicolas Thomas on Unsplash
Did you know that there are reflectors on the moon? The Apollo missions left them on the surface of the moon for scientists on earth to shoot lasers at. By measuring the time it takes for the laser to go to the moon and back, the scientists can calculate the distance from the earth to the moon with millimeter precision. This led to the discovery that the moon is spiraling away from the Earth. This experiment was called the Lunar Laser Ranging experiment, and we're going to do a software equivalent of it!
Take a look at the finished dashboard in my Filament app:
Here, you can see the regions where I have an instance of a reflector
app running. I'll be using some fancy networking magic provided by your friends at fly.io to measure how long it takes to send a simple message back and forth.
I wonder if you can calculate where I live from this data? (Please don't.)
Instead, let's pop the hood:
The Filament part
Here's my basic goal: In my database I save data about the Fly Regions, and about the Machines my reflector app is running on. The Machine model is contains a machine_id
(the ID of the machine on Fly.io) and a region_id
for the Region
relationship. We'll need the machine_id
later on for the networking part.
For every Machine, I wanted to show a widget on the Dashboard page use Header Widgets. I ran into issues though: For headerWidgets to work in Filament, you need to configure the classnames of the widgets you'll use. Making a widget at runtime for each machine and sharing with each widget what machine it belongs seemed difficult and hacky.
So, I made my own custom Filament page with php artisan make:filament-page <page-name-here>
with a foreach
loop that loops over the machines and shows a card for every machine. Quick tip: just use <x-filament::card></x-filament::card>
to use Filament's excellent styling.
Then, I just needed to add the machines. Since I'm very lazy, I added an Action on the ListMachines
index page to fetch all the machines and add them to the database. Here's how it looks:
Actions\Action::make('fetchMachines')
->action(function() {
$response = Http::withToken(env('FLY_AUTH_TOKEN'))
->get('https://api.machines.dev/v1/apps/fly-filament-satellite/machines');
$machines = json_decode($response->body());
//delete machines with different ID
$machineIDs = array_column($machines, 'id');
Machine::whereNotIn('machine_id', $machineIDs)->delete();
// create the new machines
foreach($machines as $machine)
{
$attributes = ['machine_id' => $machine->id, 'region_id' => Region::where('iata_code', $machine->region)->first()->id];
Machine::firstOrCreate($attributes);
}
})
This makes a call to the Fly.io machines API to get all the machines for the app 'fly-filament-satellite', our reflector
app. To authenticate on the machines API, you'll need an auth token. You can get this by running fly auth token
in your terminal. When I use it in Laravel apps, I usually save it in an environment variable locally or as a Fly Secret in apps running on Fly.io.
The closest region
On my dashboard, I show what the closest region is. This is the region where the request was accepted in and will be routed from. This can be any one of Fly.io's regions, your app doesn't need to be running there. The request will come into Fly.io's network on that region and will be routed to the closest region where your app has a machine running.
My closest region is London, UK. In the example above, there's an app running in only one region: Amsterdam, the Netherlands. My request would arrive on the edge node in London, which will send the request on to the app running in Amsterdam. The response would then return to me over the edge node in London.
Whenever an edge node passes on a request to an app instance, it adds some headers with useful info. Here's how a request to https://api.machines.dev/v1/apps/[appname here]
came back:
On the end of fly-request-id
, there's lhr
. This means my request entered the Fly.io network in London. Since all the fly regions are already in the database, we can easily connect a region code to the corresponding region. Cool!
The latency
This is the shooting-lasers-at-the-moon part you've been waiting for. Don't worry, it doesn't involve rocket science.
Let's take a look at our reflector
app first: This app will just respond OK to every request we throw at it. But that's not all: we need to be able to control what exact region handles our requests. We don't want the Fly Proxy picking the closest region to us!
Enter dynamic request routing™️: if our reflector app sets a fly-replay
header on the response, the Fly Proxy will replay the original request instead of sending the response to the client. If we put a region in the fly-replay
header, the original request will be replayed in the specified region. Neat! To let our reflector app know what region to replay the request in, we'll use a route parameter. So, if we send a request to https://fly-filament-satellite.fly.dev/api/ping/nrt
we want the request to be replayed to Tokyo (nrt).
Let's look at this more closely:
The closest region to me is London so that's where the request will enter the network, but the closest app instance is in Amsterdam. This is what will happen: The London
edge node will pass the request on to the Amsterdam
region, since that's geographically the shortest distance. There, the app will notice we want the request to be replayed in Tokyo because of the nrt
route parameter. The app will send a response that contains this header: fly-replay: region=nrt
. The Fly proxy will pick this up, and see the response should not go to the client. Instead, it will replay the original request to the nrt
region.
One small issue: If we always set a fly-replay
header, we'll create an infinite loop. Luckily for us, when a request has been replayed there'll be a fly-replay-src
header that shows that the request has been replayed and where it originates from. So, if that header is present on the request we just omit the fly-replay header, so the request won't be replayed then. Cool!
Here's how the only controller on my reflector
app looks:
// Route::get('/ping/{region}', PingMachine::class)->name('ping-machine');
public function __invoke(string $region, Request $request)
{
if ($request->hasHeader('fly-replay-src'))
{
return response([], 200, []);
}
else
{
return response(['current-time' => microtime(true)], 200, [
'fly-replay' => 'region=' . $region,
]);
}
}
So, whenever we send a request to /ping/nrt
, it'll be replayed to the machine running in Tokyo which will then respond to the client.
Now, all we need to do is send a request for every region our app runs in, and log the time it takes for the response to get back to us. Here's the function that's behind the 'ping all' button on the dashboard:
public function pingAllMachines()
{
foreach($this->machines as $machine)
{
$response = Http::get('https://fly-filament-satellite.fly.dev/api/ping/' . $machine->region->iata_code);
$this->latencies[$machine->machine_id] = $response->transferStats->getTransferTime();
}
}
Using transferStats->getTransferTime()
we can easily get the time between sending the request and receiving the response.
There we go, we recreated the Lunar Laser Ranging experiment with Fly Machines. Unlike the moon though, the Fly Machines won't spiral away from us. I'm sure you agree that's a good thing.
driesvints, carlx1101 liked this article