hkyss/beacon

Self-hosted traffic and Core Web Vitals from real visits, cookieless by default

Maintainers

Package info

github.com/hkyss/beacon

pkg:composer/hkyss/beacon

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-06 13:17 UTC

This package is auto-updated.

Last update: 2026-08-06 13:49:50 UTC


README

ci packagist php license

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-For is 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.