quiet-metrics / symfony-metrics
Bundle Symfony du SDK Quiet Metrics (La Boîte à Code) : pageviews serveur automatiques via kernel.terminate et configuration sémantique.
Package info
github.com/Quiet-Metrics/symfony-metrics
Type:symfony-bundle
pkg:composer/quiet-metrics/symfony-metrics
Requires
- php: >=8.1
- quiet-metrics/php-metrics: ^0.6
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
- symfony/framework-bundle: ^6.4 || ^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Symfony bundle (6.4 and 7.x) for the Quiet Metrics PHP SDK: audience measurement with no identification or tracking cookies, 100% server-side, unblockable by ad blockers. Page views are sent automatically on kernel.terminate, without JavaScript and without ever slowing the site down.
Installation
composer require quiet-metrics/symfony-metrics
With Symfony Flex, the bundle is registered automatically (symfony-bundle type). Without Flex, add it to config/bundles.php:
// config/bundles.php return [ // ... QuietMetrics\Symfony\QuietMetricsBundle::class => ['all' => true], ];
Upgrading
Each 0.x minor version of the bundle requires the matching minor version of the core SDK quiet-metrics/php-metrics: upgrade both together, and only those two.
composer require quiet-metrics/symfony-metrics:^0.6 --no-update composer update quiet-metrics/symfony-metrics quiet-metrics/php-metrics
A plain composer require fails, because the core SDK is locked in composer.lock. Composer's error message then suggests -W, which updates every dependency of the project; -w would also upgrade Symfony components. Read the CHANGELOG before upgrading: it flags changes in what gets counted.
Configuration
The configuration alias is quiet_metrics.
# config/packages/quiet_metrics.yaml quiet_metrics: public_key: '%env(QUIET_METRICS_PUBLIC_KEY)%' # site public key (required) secret_key: '%env(QUIET_METRICS_SECRET_KEY)%' # essential server-side: signs every hit (HMAC) # endpoint: 'https://quietmetrics.dev/api/v1/collect' # default: core SDK's Quiet Metrics SaaS endpoint # trust_proxy_headers: true # application behind a reverse proxy (X-Forwarded-For / X-Forwarded-Proto) # auto_pageview: false # disables the automatic pageview (manual events only) # seo_crawl: '%env(bool:QUIET_METRICS_SEO_CRAWL)%' # SEO crawl ownership proof (see below)
# .env.local
QUIET_METRICS_PUBLIC_KEY=qm_pub_xxx
QUIET_METRICS_SECRET_KEY=qm_sec_xxx
Why the secret key matters. It enables signed mode, the only case where the visitor IP and User-Agent carried by your server are trusted. Without it, every hit is attributed to your server's IP: all your visitors would count as one.
Usage
Pageviews for rendered HTML responses, including errors are sent on their own: nothing to do.
For custom events, inject QuietMetrics\Tracker, the interface implemented by the core SDK client the bundle wires (QuietMetrics\Client before version 0.5.0):
use QuietMetrics\Tracker; use Symfony\Component\HttpFoundation\Response; final class CheckoutController { public function __construct(private readonly Tracker $quietMetrics) {} public function confirm(): Response { $this->quietMetrics->event('purchase', ['amount' => 49, 'plan' => 'pro']); // ... } }
With auto_pageview: false, you keep control over page views:
// Context (URL, referrer, IP, User-Agent, language) inferred from the // current request, overridable key by key: $this->quietMetrics->pageview(); $this->quietMetrics->pageview(['url' => 'https://mysite.com/thank-you']);
In your tests
Replace the QuietMetrics\Tracker service with your own implementation to verify your events without any network. The client is final: you do not extend it, you replace the interface it implements.
# config/services_test.yaml services: QuietMetrics\Tracker: class: App\Tests\Double\RecordingTracker
Opting out of measurement
A visitor can ask to stop being counted, with no account and without writing to anyone: they visit a page of your site with ?qm_ignore=1, and ?qm_ignore=0 puts them back into measurement.
https://mysite.com/?qm_ignore=1 stop being counted
https://mysite.com/?qm_ignore=0 be counted again
The marker is a first-party cookie of your own site, named qm_ignore with the value 1 (path=/, samesite=lax, secure over https, five years). A dedicated OptOutListener takes care of it on the current request. It is registered whatever auto_pageview is set to: a refusal does not depend on a measurement option. Nothing to wire.
It holds no identifier (its value is the same for everyone), it is never transmitted to Quiet Metrics, and it exists only to stop measurement: it is an opt-out marker, not a tracker. The JS tracker additionally writes the same value to localStorage, but a server-side SDK only ever reads the cookie: one visit therefore covers both tracking modes.
Visit continuity
When the visitor fingerprint changes mid-visit (4G, then wifi), the same person would otherwise be counted as two unique visitors on the same day. A second first-party cookie of your own site closes that gap: qm_visit, value 1 (path=/, samesite=lax, secure over https), on a sliding ten-minute window pushed back by every measured hit. Each hit reports whether it was already there as the c key of the payload.
Its value is a constant, the same for everyone, so it identifies nobody: it only says that a visit is already under way in this browser. It is never written to someone who has set the opt-out marker, and never written when nothing is measured. A dedicated VisitListener writes it on kernel.response, on the very requests whose pageview TrackRequestListener sends on kernel.terminate. Unlike OptOutListener, it is registered only when auto_pageview is on: a refusal does not depend on a measurement option, but a measurement cookie does.
Note for cached sites: a measured response now carries a Set-Cookie header, which some reverse proxies and CDNs treat as a reason not to store the response.
SEO crawl
The SEO tab of Quiet Metrics only crawls a site that proves it belongs to the account that declared it. With the secret key configured, turn the option on and the bundle serves that proof automatically at /.well-known/quietmetrics.json:
# config/packages/quiet_metrics.yaml quiet_metrics: # ... seo_crawl: '%env(bool:QUIET_METRICS_SEO_CRAWL)%'
# .env (committed default, off) QUIET_METRICS_SEO_CRAWL=false # .env.local (or the server environment) QUIET_METRICS_SEO_CRAWL=true
Declare the default in .env: %env()% fails at runtime on an undefined variable. The document is {"site_verification":["<token>"]}, where the token is an HMAC-SHA256 computed with your secret key (never the key itself; the public key would not do, since it can be read in your pages' HTML).
SiteVerificationListener answers on kernel.request, before the router, for GET and HEAD on that exact path only, main request only, with Content-Type: application/json and Cache-Control: no-store: no route to declare. Off by default: without seo_crawl, or without secret_key, it lets every request through untouched, the path belongs to your application (its 404, or its own route), and no crawl happens.
How it works
- Sending happens on
kernel.terminate: the response has already reached the visitor, zero perceived latency. The core SDK client is itself non-blocking (write-and-forget socket, short-timeout cURL fallback, silent failures): analytics never breaks the host site. - The context is read from the
Requestobject (never from superglobals): correct under RoadRunner and FrankenPHP, in tests, and aligned with the host application's trusted proxies. - With
secret_key, every send is HMAC-SHA256 signed (X-QM-TimestampandX-QM-Signatureheaders); the visitor IP and User-Agent carried by the SDK are then trusted on the collection side. - Only what the platform reads is sent: the page address reduced to its origin, its path and the
utm_source,utm_medium,utm_campaignandrefparameters; the referrer reduced to its origin.
Automatic pageviews are HTML/XHTML documents rendered in response to a GET, including 404/500 errors. Redirects, empty responses (204/205), PDFs, JSON and attachments are excluded, along with AJAX, announced prefetches and opted-out visitors.
From version 0.4.0, track_404: true also emits a 404 event with the page path. This option is off by default: each additional event consumes quota. Enable it server-side or through the script’s data-404 on a given page to avoid duplicate events. Server tracking cannot see pages served by a cache that bypasses PHP.
License
MIT. A La Boîte à Code product for Quiet Metrics.
