hkyss / beacon
Self-hosted traffic and Core Web Vitals from real visits, cookieless by default
Requires
- php: ^8.2
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- nyholm/psr7: ^1.8
- orchestra/testbench: ^10.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
- psr/http-factory: ^1.0
- psr/http-server-middleware: ^1.0
Suggests
- ext-pdo: Required by the PDO storage driver and the PDO throttle counter
- illuminate/support: Required by the Laravel and Evolution CMS integrations
- psr/http-factory: Required by the PSR-15 integration
- psr/http-server-middleware: Required by the PSR-15 integration
README
Self-hosted traffic and Core Web Vitals from real visits — in your own database, with no cookie and no third party.
- Page views with referrer, device class and campaign tags, and LCP, INP, CLS, TTFB and FCP measured in real browsers.
- Cookieless by default: no address stored, a visitor token salted and rotated at midnight, Do Not Track honoured before anything is sent.
- SQLite, MySQL, PostgreSQL, or newline-delimited JSON files.
- No runtime dependencies and no build step. The agent inlines before
</body>: 5.9 KB, about 2.2 KB compressed. - No dashboard. Reports come back as arrays and you draw them.
Install
composer require hkyss/beacon:^0.1
A runtime dependency, not a dev one — this runs in production.
The namespace is hkyss\Beacon\, lowercase vendor. Composer matches PSR-4
prefixes case-sensitively, so Hkyss\Beacon\ compiles and never autoloads. Copy
the imports as written.
Enable
BEACON=anonymous BEACON_SECRET=a-long-random-string BEACON_SITE=shop BEACON_RETENTION_DAYS=90
BEACON decides what is kept:
| Value | What is stored |
|---|---|
false |
Nothing. The agent is not even injected |
anonymous |
Page, referrer host, device class, campaign tags, and a daily visitor token. No address, no user agent, no cookie |
full |
All of the above plus the raw address and user agent |
Without BEACON_SECRET there is no visitor token: page views still count,
unique visitors read zero. full stores personal data, and the obligations that
come with it are yours.
| Variable | Default | |
|---|---|---|
BEACON_SECRET |
— | Salt for the daily visitor token |
BEACON_SITE |
default |
Which site the events belong to, when one install serves several |
BEACON_SAMPLE |
1.0 |
Share of visitors kept, 0.0 to 1.0 |
BEACON_ENDPOINT |
/beacon |
Where the agent posts |
BEACON_RESPECT_DNT |
1 |
Honour Do Not Track and Global Privacy Control |
BEACON_RETENTION_DAYS |
0 |
Days to keep an event; 0 keeps everything |
BEACON_THROTTLE |
120 |
Deliveries per address per minute; 0 removes the limit |
BEACON_STORAGE |
pdo |
pdo, jsonl or null |
BEACON_PATH |
— | Where JsonlStorage writes |
BEACON_PREFIX |
beacon_ |
Table prefix for PdoStorage |
The last three are read by the Laravel config, which builds the storage for you.
Elsewhere you construct the driver yourself and Config::fromEnv() ignores them.
The ingest endpoint is public
A beacon is sent by sendBeacon during unload: no session, no CSRF token, no
headers it can set. BEACON_THROTTLE caps deliveries per address per minute.
The window slides, so a burst cannot spend one limit either side of a minute
boundary.
Laravel puts its own ThrottleRequests on the route and answers 429.
Everywhere else Throttle counts, and a refused delivery is still answered
204. Either way the address is salted and hashed before anything is written,
and a counter that cannot be kept means no throttling rather than no analytics.
use hkyss\Beacon\Throttle; use hkyss\Beacon\Counter\PdoCounter; new Throttle($config); // files in the system temp directory new Throttle($config, new PdoCounter($pdo)); // one count for the whole site
Files count per machine, so each server behind a balancer allows the full limit.
PdoCounter counts in the shared database and needs its table — Beacon::migrate()
or (new PdoCounter($pdo))->migrate().
One delivery carries at most 20 events and at most 64 KB.
Content-Security-Policy
The agent is inline. Under a script-src without 'unsafe-inline' both tags
need this request's nonce, or the agent is blocked with nothing to show for it
but a console error in production.
Laravel and PSR-15 need no configuration: the middleware reads the csp-nonce
request attribute, and Laravel falls back to Vite::cspNonce(). Override
nonce() on the Laravel middleware if yours lives elsewhere. Injecting by hand:
echo Beacon::inject($html, $nonce);
No nonce means no nonce attribute, which is right for a site with no policy.
Storage
use hkyss\Beacon\Storage\PdoStorage; $storage = new PdoStorage($pdo); $storage->migrate();
Two tables, beacon_events and beacon_metrics, in the dialect your driver
speaks. migrate() is safe to run twice. JsonlStorage writes one file per UTC
day and needs no database, but aggregates in PHP. NullStorage is the default
when nothing is configured.
Every timestamp, bucket label and retention window is UTC. Writing a driver of
your own is seven methods against hkyss\Beacon\Storage\Storage, held to the
built-in behaviour by tests/StorageContract.php.
Laravel
The service provider is auto-discovered. It registers the ingest route, inlines the agent into HTML responses, and adds two commands.
php artisan vendor:publish --tag=beacon-config php artisan beacon:migrate
Schedule::command('beacon:prune')->daily();
Publish the config only to set something in code rather than in the environment.
The ingest route sits outside the web middleware group: a beacon carries no
CSRF token, and the session stack would set a cookie for every visitor. Its one
middleware is the rate limit — override ingestMiddleware() to add your own.
Evolution CMS 3
// core/custom/config/app/providers/BeaconServiceProvider.php <?php return \hkyss\Beacon\Integration\Evolution\BeaconServiceProvider::class;
Injection and ingest both go through OnWebPagePrerender, which covers pages
served from the EVO page cache and works without a router. Set BEACON and
BEACON_SECRET in core/custom/.env or in the web server environment.
The provider builds its storage from EVO's own database connection, so all that is left is creating the tables once, from a CLI script inside EVO:
\hkyss\Beacon\Beacon::migrate();
PSR-15
use hkyss\Beacon\{Agent, Config, Ingest, Throttle}; use hkyss\Beacon\Counter\PdoCounter; use hkyss\Beacon\Integration\Psr15\BeaconMiddleware; use hkyss\Beacon\Storage\PdoStorage; $config = Config::fromEnv(); $storage = new PdoStorage($pdo); $pipeline->pipe(new BeaconMiddleware( new Ingest($config, $storage), new Agent($config), $streamFactory, $responseFactory, $config->endpoint(), new Throttle($config, new PdoCounter($pdo)), ));
One middleware answers POST /beacon and inlines the agent into every other
HTML response. The throttle is optional — leave it out if the pipeline already
has a rate limiter in front.
Plain PHP
use hkyss\Beacon\{Beacon, Config}; use hkyss\Beacon\Storage\PdoStorage; Beacon::boot(Config::fromEnv(), new PdoStorage($pdo)); Beacon::migrate(); // once, not on every request // beacon.php — whatever BEACON_ENDPOINT points at Beacon::receive(); http_response_code(204); // every page echo Beacon::inject($html);
Beacon is for code with no container to ask. Where there is one, build
Ingest, Report and Agent yourself — the Laravel integration does.
| Call | |
|---|---|
Beacon::boot(?Config, ?Storage, ?Throttle) |
Wire it up once; returns the resolved config |
Beacon::receive(?Payload) |
Take one delivery; returns how many events were stored |
Beacon::inject($html, ?$nonce) |
Agent inserted before </body> |
Beacon::migrate() |
Create the event tables and the throttle counter |
Beacon::prune(?$now) |
Delete past the retention window; returns rows removed |
Beacon::report() |
The Report for the configured site |
Beacon::collecting() |
Whether the mode is anything but off |
Reports
use hkyss\Beacon\{Period, Report}; $report = new Report($storage, 'shop'); $traffic = $report->traffic(Period::days(30)); // ['views' => 12480, 'visitors' => 5133, 'timeline' => [...], // 'pages' => [...], 'referrers' => [...], 'devices' => [...], ...] $vitals = $report->vitals(Period::days(7)); // [['metric' => 'lcp', 'samples' => 4021, 'p50' => 1180.0, // 'p75' => 2410.0, 'p95' => 5200.0, 'rating' => 'good'], ...] $worst = $report->slowestPages(Period::days(7)); // the pages people land on most, worst rating first
Ratings are p75 against Google's thresholds. samples is how many measurements
a summary was computed from; past twenty thousand in one period it is a sample
of that size, drawn across the whole period rather than its first hour.
What it does not do
- No dashboard. Reports return arrays; the drawing is yours.
- No geography. Country from address needs a GeoIP database.
- No sessions, funnels or goals. Beacon counts visits and measures pages.
- Bots are dropped, not counted. Detection is a substring list, so it stops the honest crawlers and nothing else.
- No proxy headers. The address is
REMOTE_ADDR;X-Forwarded-Foris attacker-controlled unless the app has already decided which proxies to trust.
Development
composer install composer check # style, PHPStan level 8, PHPUnit npm install npm test # the browser agent, in jsdom
SQLite always runs; MySQL and PostgreSQL run the same contract when you point the tests at a server and skip when you do not. CONTRIBUTING.md has the variables.
License
MIT — see LICENSE.