Support the ongoing development of Laravel.io →
Article Hero Image

FilamentPHP: Shooting lasers at the moon

15 Aug, 2023 7 min read

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.

Last updated 3 weeks ago.

driesvints, carlx1101 liked this article

2
Like this article? Let the author know and give them a clap!

Other articles you might like

Article Hero Image November 18th 2024

Laravel Custom Query Builders Over Scopes

Hello 👋 Alright, let's talk about Query Scopes. They're awesome, they make queries much easier to r...

Read article
Article Hero Image November 19th 2024

Access Laravel before and after running Pest tests

How to access the Laravel ecosystem by simulating the beforeAll and afterAll methods in a Pest test....

Read article
Article Hero Image November 11th 2024

🍣 Sushi — Your Eloquent model driver for other data sources

In Laravel projects, we usually store data in databases, create tables, and run migrations. But not...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.