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.
composer require hkyss/beacon:^0.1A 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.
BEACON=anonymous
BEACON_SECRET=a-long-random-string
BEACON_SITE=shop
BEACON_RETENTION_DAYS=90BEACON 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.
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 siteFiles 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.
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.
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.
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:migrateSchedule::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.
// 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();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.
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 |
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 firstRatings 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.
- 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.
composer install
composer check # style, PHPStan level 8, PHPUnit
npm install
npm test # the browser agent, in jsdomSQLite 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.
MIT — see LICENSE.