php-bug-catcher / perf-collector-bundle
Symfony integration for the Bug Catcher performance collector: the aggregator as a console command, and Doctrine SQL metrics in every sample
Package info
github.com/php-bug-catcher/perf-collector-bundle
Type:symfony-bundle
pkg:composer/php-bug-catcher/perf-collector-bundle
Requires
- php: >=8.3
- php-bug-catcher/perf-collector: ^2.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- ext-pdo_sqlite: *
- doctrine/dbal: ^4.0
- phpunit/phpunit: ^11.5
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
Suggests
- doctrine/dbal: To count Doctrine queries and the time they take into every performance sample
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-05 14:33:36 UTC
README
Symfony integration for
php-bug-catcher/perf-collector: the
aggregator as a console command configured once in config/packages/, the Doctrine query count
and query time added to every performance sample, and a console run named after the command it
ran.
auto_prepend_file hook ──▶ /dev/shm/bcperf.jsonl ──▶ bug-catcher:perf-aggregate ──HTTP──▶ Bug Catcher
(one line/request) (local only) (cron, every minute)
The collector does not need this bundle. A non-Symfony application installs the hook and runs
bc-perf-aggregate from cron, and loses nothing but the SQL numbers and the command names. What
the bundle adds is saying the aggregator's eight command-line options once, in configuration; an
#[AsEventListener] that puts sq (queries) and st (seconds in the database) into every
sample; and another that names a console run after the command Symfony resolved rather than after
bin/console.
Install
composer require php-bug-catcher/perf-collector-bundle
// config/bundles.php return [ // ... BugCatcher\PerfCollectorBundle\PerfCollectorBundle::class => ['all' => true], ];
1. The hook, in php.ini
The hook is a plain PHP file that runs before your autoloader; there is nothing to register in
Symfony. Point auto_prepend_file at it and tell it where to write:
; /etc/php.d/90-bcperf.ini (or your pool's php.ini) auto_prepend_file = /srv/app/vendor/php-bug-catcher/perf-collector/hook/collector.php bcperf.log = /dev/shm/bcperf.jsonl
Put the log on tmpfs. The append is the only part of the hook whose cost is not under its
control: the collector measured the same write at ~2 µs on tmpfs and ~350 µs on a journalling
filesystem - three orders of magnitude, and more than everything else the hook does put
together. /dev/shm is the right default; the aggregator empties the file every minute, so
nothing in it is meant to survive a reboot.
BCPERF_LOG, BCPERF_SAMPLE_RATE and BCPERF_CLI do the same job as environment variables, and
win over php.ini when both are set. php-bug-catcher/perf-collector's README documents them.
2. The configuration
# config/packages/bug_catcher_perf_collector.yaml bug_catcher_perf_collector: log: '%env(BCPERF_LOG)%' endpoint: 'https://bugcatcher.example.com' project: 'myapp'
Reading BCPERF_LOG here is worth doing on purpose: the hook and the aggregator then cannot
disagree about which file they are talking about, and a rotation pattern inside an environment
variable needs no escaping. Writing the path out in YAML does - see the note under log.
3. The cron entry
On every monitored machine, every minute:
* * * * * php /srv/app/bin/console bug-catcher:perf-aggregate
bc-perf-aggregate --log=… --endpoint=… --project=… from the collector package does exactly the
same thing without reading your configuration; use whichever suits the deployment. The exit codes
are identical.
Check the setup once before the cron entry goes in - this reads and reports, ships nothing and moves no cursor:
php bin/console bug-catcher:perf-aggregate --dry-run
The server side has cron entries of its own (roll-up, retention, regression detection); they are documented with the Bug Catcher server, not here.
Configuration
| Key | Default | Meaning |
|---|---|---|
log |
required | The log the hook writes, which is to say BCPERF_LOG. A rotation pattern is spelled %%Y%%m%%d in YAML - the container reads a single per cent sign as a parameter reference and will refuse to start. '%env(BCPERF_LOG)%' sidesteps the question. |
endpoint |
required | Base URL of the Bug Catcher server. The ingest path is the collector's business. |
project |
required | The project code the samples belong to, as Bug Catcher spells it. |
token |
null |
Sent as a bearer credential. |
state_dir |
%kernel.project_dir%/var/bcperf |
Read cursors and, by default, the lock file. Not under %kernel.cache_dir%: cache:clear is a deploy step, and a lost cursor is how a window gets counted twice. |
lock_file |
aggregate.lock inside state_dir |
Two runs never read the same log at once. |
rules_file |
null |
A JSON list of extra path normalisation rules, applied before the shipped ones. |
default_rules |
true |
false makes rules_file the whole list instead of an addition to it. |
max_bytes |
16777216 |
Most bytes read in one run. A backlog from a server outage drains over several runs rather than becoming one request that can never succeed. |
sql_metrics |
true |
Count Doctrine queries and their time into every sample. Needs doctrine/dbal; without it nothing is registered. |
console_path |
true |
Name a console run after the command Symfony resolved and its positional arguments. Without it the hook reads the first non-option token of argv, so bin/console --env prod app:sync is recorded as /console/prod and an alias is recorded as itself. |
console_path_arguments |
true |
false keeps the command name and drops the argument values. For an application whose arguments are an e-mail address or an order reference, that is the difference between a path per job and a path per invocation. |
console_path_redact |
[] |
Argument names whose value is replaced by {redacted}. Added to the built-in list, never replacing it. |
SQL metrics
With doctrine/dbal installed, a DBAL middleware times every query and an #[AsEventListener]
on kernel.terminate and console.terminate writes two numbers into
$GLOBALS['_bcperf_extra']:
| Key | Type | Meaning |
|---|---|---|
sq |
int |
Queries this request ran, failed ones included - they cost the database the same work. |
st |
float |
Seconds spent in the database. Seconds, like every other duration the collector ships. |
The hook reads that global from its shutdown handler, which runs after kernel.terminate and
after fastcgi_finish_request() - the response has already gone to the client, so none of this
is on the visitor's clock. The two numbers are summed per bucket by the aggregator and stored by
the server beside the request counts, so a route's queries per hit are one division away.
Nothing about this is Doctrine-specific. Any numeric value an application assigns to
$GLOBALS['_bcperf_extra'] before the process ends travels the same way - a cache hit rate, a
template count, time spent in an upstream API:
$GLOBALS['_bcperf_extra']['api_ms'] = $elapsedMilliseconds;
Names may be up to 32 characters of letters, digits, underscore, dot and dash, and values must
be int or float; the hook drops anything else.
What a console run is called
A request is named by its URI. A command has none, so it is named after the command Symfony resolved and the positional arguments it was given:
php bin/console app:import customers --full -vv
-> /console/app:import/customers
Options are dropped, positional arguments are kept. --full says how a job was asked to run;
customers says which job it was. Keeping the arguments is what tells app:import customers apart
from app:import orders - two different amounts of work that would otherwise share one row - and
dropping the options is what stops that becoming a path per invocation.
The hook can name a command-line run on its own, and this is strictly better at it, because a
kernel knows three things argv does not:
the hook, from argv |
this bundle, from Symfony | |
|---|---|---|
bin/console --env prod app:sync |
/console/prod - prod is the first non-option token |
/console/app:sync |
bin/console app:imp (an abbreviation) |
/console/app:imp |
/console/app:import |
bin/console app:legacy-import (an alias) |
/console/app:legacy-import |
/console/app:import |
app:user:create bob hunter2 |
one argument only, and no idea which | /console/app:user:create/bob/{redacted} |
$_SERVER['REQUEST_URI'] is the channel, which is the hook's own idea - it has preferred that
variable over its own guess since 2.0, so nothing on the monitored machine has to change: no
new hook, no php.ini edit. By the same rule, a worker that sets REQUEST_URI itself is believed
over its command line, because it is saying what it stands in for.
Nothing is published unless the hook is switched on for the process, which for a command line
means bcperf.cli = 1 (or BCPERF_CLI=1) as well as a log path. An application whose
auto_prepend_file lives in the php-fpm pool has no hook on the command line at all, and measures
no commands - with or without this bundle.
What reaches the path, exactly
-
Defaults count.
app:migrateandapp:migrate latestare one path whenlatestis the default, because they are one piece of work. -
An array argument spreads, one segment per element:
messenger:consume async failedis/console/messenger:consume/async/failed. For a worker the receiver names are its identity. -
An argument that was not given reads
{none}, unless it is at the end, where it is simply dropped - so an unused optional argument leaves/console/app:importas it was. -
A sensitive name reads
{redacted}, in place rather than left out, so that nothing after it shifts position. The built-in list is matched case-insensitively as a substring of the argument's definition name, so--db-passwordanduserPasswordare both caught:password,passwd,passphrase,secret,token,credential,apikey,api_key,api-key,privatekey,private_key,private-key,signature,webhookShort, ambiguous words are missing on purpose: a bare
keywould redactkeyword,authwould redactauthor,pinwould redactpinned. Add those withconsole_path_redact, where the application can see the decision it made. -
{id},{uuid}and{hash}are not this bundle's work. A numeric argument reaches the log as itself and is collapsed by the aggregator's normalisation rules, the same ones that turn/user/4711into/user/{id}.
Think of the path as a name, not as data, because that is what it becomes: it is the identity of a
bucket on the server, it lives two years in the day aggregates and for ever in a regression
record, and it is read back out by notifier e-mails and over MCP. There is no scrubbing it
afterwards. That is the reason for the denylist, and the reason console_path_arguments: false
exists for an application whose arguments are nobody's business.
Three caps keep a name a name. A segment is cut at 64 bytes; at most eight argument segments are
kept; and the whole path is held under 512 bytes by dropping whole segments off the end, never by
cutting one - the script and the command are never reached. Characters outside
A-Za-z0-9._:- become -, which means a wholly non-ASCII argument reads as {none}: an
ASCII-only alphabet is what makes one cap a byte cap and a character cap at once, so the hook's
truncation and the server's length check can never disagree.
One side effect, honestly. $_SERVER['REQUEST_URI'] is what several libraries read as "this is
a web request" - Monolog's WebProcessor attaches a url to a record only when it is set. In a
recorded console process your CLI log records may therefore gain a url that looks like a route.
That is why console_path: false exists as more than a cardinality knob.
Upgrading. A plainly invoked bin/console app:sync keeps the name it already had. The
invocations that get renamed are the ones that were wrong - --env prod, aliases, abbreviations -
and any command with arguments. A renamed path is a new bucket: the old row stops growing, and the
server needs a week of history before it can call a regression on the new one again.
Exit codes
| Code | Meaning |
|---|---|
0 |
Done - including when another run already held the lock. |
1 |
The batch did not reach the server. The cursor has not moved, so the next minute retries the same window; cron sends the mail. |
2 |
The configuration is wrong - an unreachable state directory, a rules file that is not there. Retrying will not help. |
License
MIT.