ayaou / command-logger-bundle
This bundle logs all commands executed with console, along with their execution time and errors if applied.
Package info
github.com/ayaou/command-logger-bundle
Type:symfony-bundle
pkg:composer/ayaou/command-logger-bundle
Requires
- php: >=8.1
- doctrine/doctrine-bundle: ^2 || ^3
- doctrine/orm: ^2 || ^3
- psr/log: ^1 || ^2 || ^3
- symfony/console: 6.4.* || 7.4.* || ^8
- symfony/dependency-injection: 6.4.* || 7.4.* || ^8
- symfony/event-dispatcher: 6.4.* || 7.4.* || ^8
- symfony/framework-bundle: 6.4.* || 7.4.* || ^8
- symfony/http-foundation: 6.4.* || 7.4.* || ^8
- symfony/http-kernel: 6.4.* || 7.4.* || ^8
- symfony/property-access: 6.4.* || 7.4.* || ^8
- symfony/property-info: 6.4.* || 7.4.* || ^8
- symfony/routing: 6.4.* || 7.4.* || ^8
- symfony/serializer: 6.4.* || 7.4.* || ^8
- symfony/uid: 6.4.* || 7.4.* || ^8
- symfony/validator: 6.4.* || 7.4.* || ^8
- symfony/yaml: 6.4.* || 7.4.* || ^8
Requires (Dev)
- friendsofphp/php-cs-fixer: *
- phpstan/phpstan-symfony: *
- phpunit/phpunit: *
- rector/rector: *
- roave/security-advisories: dev-latest
- symfony/phpunit-bridge: 6.4.* || 7.4.* || ^8
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.11.0
- v1.10.0
- v1.9.0
- v1.8.0
- v1.7.1
- v1.7.0
- v1.6.0
- v1.5.1
- v1.5.0
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- dev-feat/capture-command-output
- dev-refactor/solid-review-findings
- dev-feat/configurable-entity-manager
- dev-refactor/dbal-writes
- dev-fix/listeners-never-break-the-command
- dev-docs/api-route-prefix
- dev-ci/quality-gates
- dev-feat/statistics-endpoint
- dev-feat/command-execution-statistics
- dev-ci/manual-release-trigger
- dev-feat/expose-rest-api
- dev-fix/detect-invokable-commands
- dev-feat/redact-sensitive-parameters
- dev-fix/tests-filet-de-securite
- dev-chore/setup-outillage-de-test
- dev-ci/ajouter-symfony-8-1
- dev-feature/support-symfony-8-and-orm-3
- dev-copilot/fix-38da0774-2f8a-4a07-9a13-fa6964fd8808
- dev-fix/fix-support-new-symfony-commands
- dev-release_1.5
This package is auto-updated.
Last update: 2026-09-04 09:36:41 UTC
README
Overview
The Command Logger Bundle is a Symfony bundle that logs executed console commands. It provides insights into command execution, including arguments, execution time, exit codes, and errors.
Installation
Install the bundle via Composer:
composer require ayaou/command-logger-bundle
Register the bundle in config/bundles.php if not automatically added:
Ayaou\CommandLoggerBundle\AyaouCommandLoggerBundle::class => ['all' => true],
Configuration
Add the following configuration in config/packages/command_logger.yaml:
command_logger: enabled: true # Enable or disable logging (default: true) purge_threshold: 100 # Days after which old logs are deleted (e.g., 100 means logs older than 100 days are removed) commands: # List of commands to log if they are not annotated, we can also use wildcards (This can be useful for commands located in third-party bundles) - app:example-command - app:another-command - make:* sensitive_parameters: # Argument/option names matching one of these substrings (case-insensitive) have their value replaced with [REDACTED] before being logged. Set to [] to disable redaction. Default: - password - passwd - secret - token - api-key - api_key - apikey - credential - auth max_error_message_length: 65535 # Maximum byte length of the stored error message; longer messages are truncated (multi-byte safe) and suffixed with " [truncated]" (default: 65535, minimum: 100) entity_manager: ~ # Name of the Doctrine entity manager the command_log table lives in. Null (default) targets the default entity manager. See "Using a Separate Entity Manager" below. output_capture: enabled: false # Store what a watched command printed. Disabled by default. See "Capturing Command Output" below. max_length: 16384 # Maximum byte length of the captured output, and the exact upper bound on the memory this holds during a run (default: 16384, minimum: 100)
Usage
Enabling Logging on a Command
Use configuration commands array (if not using attributes) Or:
Use the CommandLogger attribute on any Symfony command to enable logging:
use Ayaou\CommandLoggerBundle\Attribute\CommandLogger; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'app:example-command')] #[CommandLogger] class ExampleCommand extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Executing example command...'); return Command::SUCCESS; } }
Entity Structure
The logs are stored in the command_log table with the following fields:
commandName– Name of the executed commandarguments– Command arguments in JSON formatstartTime– Execution start timeendTime– Execution end timeexitCode– Command exit codedurationMs– Execution duration in milliseconds, ornullwhen unknown (see "Command Execution Statistics" below)errorMessage– Error message if applicableoutput– What the command printed, ornullunless output capture is enabled (see "Capturing Command Output" below)executionToken– Unique identifier for execution tracking
Using a Separate Entity Manager
By default, the command_log table lives in the application's default Doctrine entity
manager, alongside the application's own entities. Setting entity_manager targets a different,
named entity manager instead - typically one backed by its own, separate database connection:
# config/packages/doctrine.yaml doctrine: dbal: default_connection: default connections: default: # ... your application's usual connection command_logs: url: '%env(resolve:COMMAND_LOGS_DATABASE_URL)%' orm: default_entity_manager: default entity_managers: default: connection: default mappings: App: is_bundle: false dir: '%kernel.project_dir%/src/Entity' prefix: App\Entity command_logs: connection: command_logs # No "mappings" key needed here for CommandLoggerBundle: the bundle adds its own # mapping to this entity manager automatically, based on "entity_manager" below.
# config/packages/command_logger.yaml command_logger: entity_manager: command_logs
Two consequences follow from this, both intentional:
- Schema creation needs
--em=command_logs. Sincecommand_logis no longer part of the default entity manager, Doctrine's own commands must be told which one to target, e.g.bin/console doctrine:schema:update --em=command_logs --force(or the equivalent migration command for your setup). - The log no longer participates in the application's database transactions. A command log entry lives in its own connection, so it is written independently of - and survives - a rollback of whatever the application itself was doing. That is precisely the point of moving it to a separate database: an execution log should still exist even when the command it describes failed and rolled back its own changes.
Capturing Command Output
By default a log row tells you whether a command succeeded, not what it said while doing so. Turn on output capture and the row carries the command's own output next to its exit code:
# config/packages/command_logger.yaml command_logger: output_capture: enabled: true max_length: 16384
$ bin/console app:load
[OK] 3 products loaded
$ bin/console command-logger:show --id=5
ID: 5
Command: app:load
Exit Code: 0
...
Output:
[OK] 3 products loaded
The captured output is also exposed on the API's item endpoint, in the command_log:item
serialization group.
Why it is off by default
A command may print anything, and that includes secrets. The sensitive_parameters
redaction works on argument and option names; nothing can reliably recognise a secret
inside free-form prose. Storing output is therefore a decision you make explicitly, for
commands whose output you know.
What it costs the observed command
Nothing while disabled: no stream filter is registered and no stream is touched.
While enabled, the cost is one bounded string append per write:
| without capture | with capture | |
|---|---|---|
| a 500-line command | 0.367 ms | 0.640 ms |
| a 100,000-line command | 77 ms | 132 ms |
Memory is bounded by max_length and by nothing else. Once that many bytes have been
captured, nothing further is retained and the stored value is suffixed with
" [truncated]" - so a command printing a gigabyte costs exactly what one printing a
kilobyte costs.
What it captures, and what it does not
Capture works by observing the stream behind the command's output, so:
- stdout and stderr written through Symfony's
OutputInterfaceare captured - including everythingSymfonyStyleproduces. echo,printand PHP warnings are not. They go through PHP's own output layer, which stream filters never see.- Outputs with no stream behind them are left alone -
NullOutputandBufferedOutputcapture nothing, and are not disturbed. - A command killed before it terminates leaves
outputnull, exactly as it leavesendTimeandexitCodenull: nothing runs to store what it had printed so far. - Escape sequences are stripped before storage. Colours are not preserved, on purpose:
the value is replayed later by
command-logger:showand by the API, and a stored escape sequence is an instruction for whichever terminal renders it.
Failure Behavior
The bundle never lets its own storage break the command it is logging. If writing to the
command_log table fails for any reason — the table doesn't exist yet, the database is
unreachable, the entity manager was already closed by a previous error — the listener gives up on
that write and lets the command carry on (or, for the error listener, lets the original exception
propagate to the user unchanged).
When this happens, an error is emitted on the application's logger service (if one is
configured), naming the command and including the underlying exception. If no logger service is
available, the bundle stays silent and the command is unaffected either way.
The level is error, not warning, for two reasons. A log entry that could not be written is a
runtime error in PSR-3 terms, not an exceptional-but-benign occurrence. And Symfony's fallback
logger — the one an application without Monolog gets — only emits error and above, so a warning
would have been dropped on exactly the bare setups where a silent empty table is hardest to
diagnose.
Show Command Logs
The command-logger:show command displays logged command executions from the command_log table. It supports filtering, pagination, and viewing specific entries by ID.
bin/console command-logger:show [name] [--limit=LIMIT] [--code=CODE] [--id=ID] [--error] [--success] [--from=FROM] [--to=TO]
Description
This command retrieves and displays command execution logs. By default, it shows the latest 10 entries, ordered by startTime in descending order, in a tabular format. You can filter by command name, exit code, or success/error status, and view a single entry by ID.
The command supports pagination, allowing you to press Enter to view more entries interactively.
Arguments
- name (optional): Filters logs by the command name (e.g., app:example-command).
Options
- --limit|-l (optional): Specifies the number of entries to show per page (default: 10).
- --code|-c (optional): Filters logs by a specific exit code (e.g., --code=0 for successful commands).
- --id (optional): Displays a single log entry by its ID (e.g., --id=123). When used, no other arguments or options are allowed.
- --error (optional): Filters logs to show only entries with non-zero exit codes (indicating errors). Cannot be used with --success or --code.
- --success (optional): Filters logs to show only entries with an exit code of 0 (indicating success). Cannot be used with --error or --code.
- --from (optional,
Y-m-dorY-m-d H:i:s): Only include logs started on or after this date/time. - --to (optional,
Y-m-dorY-m-d H:i:s): Only include logs started on or before this date/time.
Purging Old Logs
The bundle includes an automatic mechanism to purge logs older than the configured purge_threshold. You can also manually trigger log cleanup using the following command:
bin/console command-logger:purge
By default, this uses the purge_threshold value from the configuration. To override it, specify a custom threshold (in days) with the --threshold or -t option:
bin/console command-logger:purge --threshold=30
For example, --threshold=30 removes logs older than 30 days
Command Execution Statistics
⚠️ Schema update required. The
durationMscolumn is new. After upgrading, rundoctrine:schema:update --force(or apply an equivalent migration) before using this feature. Rows logged before the upgrade have no way to recover a duration and will keepdurationMs = nullforever; they are excluded from the duration averages/min/max below, not counted as zero.
The command-logger:stats command aggregates the command_log table into a summary, a
breakdown by exit code, and a breakdown by command name.
bin/console command-logger:stats [name] [--status=STATUS] [--code=CODE] [--from=FROM] [--to=TO] [--limit=LIMIT]
Arguments
name(optional): Filters statistics by command name (exact match on the "name contains" filter, same as thenamequery parameter of the REST API).
Options
These mirror the REST API's CommandLogFilter exactly, so the numbers here are computed over
the same rows a matching GET /command-logs request would list:
--status(optional):successorerror. Cannot be combined with--code.--code|-c(optional): Filters by a specific exit code.--from(optional,Y-m-dorY-m-d H:i:s): Only include logs started on or after this date/time.--to(optional,Y-m-dorY-m-d H:i:s): Only include logs started on or before this date/time.--limit|-l(optional, default10): Number of commands to show in the per-command breakdown.
Metrics
- Total – Number of logs matching the filter.
- Successes – Logs with
exitCode = 0. - Failures – Logs with
exitCode != 0. - Unfinished – Logs with
endTime IS NULL. This means the command never reachedconsole.terminate: it may have been killed, crashed, or it may simply still be running right now, at the moment the statistics were computed. There is no way to tell the two apart from this table alone. - Failure rate –
Failures / Total, computed in PHP so an empty result set never divides by zero. - Duration (avg / min / max) – In milliseconds, computed only over logs that actually have
a
durationMsvalue. Unfinished logs and logs predating the schema update are excluded from these figures rather than treated as0. - Measured executions – How many of the matched logs contributed a
durationMsvalue to the duration figures above. - Breakdown by exit code – Count of logs for each distinct, non-null
exitCode. - Breakdown by command – The same set of metrics grouped by
commandName, sorted by total volume (descending) and capped at--limitentries.
These aggregations only use COUNT, AVG, MIN, MAX and GROUP BY, so they run unchanged
on every database engine this bundle supports — there is no median, no percentile, and no
time-bucketed series in this feature.
REST API
The bundle can optionally expose the command log history over a read-only JSON-LD/Hydra REST API. It is disabled by default and must be opted into explicitly.
⚠️ Security warning
These endpoints expose the full command execution history, including command arguments. They ship with no access control of any kind — anyone who can reach the routes can read every logged command and its arguments. Protecting them is entirely the consuming application's responsibility: restrict the path with
access_controlinsecurity.yaml(or an equivalent firewall rule) before exposing this API outside of trusted networks. This is the single most important thing to get right before enabling this section — read it before you do.Enabling
api.enabledmay be all it takes to expose the routes. On a Symfony 7.4 or 8.x application (any skeleton generated with the defaultconfig/routes.yaml, which importsresource: routing.controllers), that loader automatically routes the#[Route]attributes of every controller registered as a service — including this bundle's, the momentapi.enabledturns it into one. The routes then appear indebug:routerwith no import ofconfig/routes.yamlrequired at all. Symfony 6.4 skeletons scope that same loader toApp\Controlleronly, so there the explicit import below is still what exposes the API. Either way, put theaccess_controlrule in place before settingapi.enabled: truein any environment you don't fully control — do not rely on "I haven't imported the routes yet".
Enabling the API
Two things gate the API, and both are opt-in — and a third, protecting it, is on you:
1. Turn on the API services — this is the flag that matters most, since it is what can expose the routes on its own on Symfony 7.4/8.x (see the warning above):
# config/packages/command_logger.yaml command_logger: api: enabled: true
2. Find out where the routes actually are, with bin/console debug:router. Do not assume —
the answer depends on your application's config/routes.yaml, and it decides which path you have
to protect.
If they are already listed (Symfony 7.4/8.x default skeleton), step 1 was enough: the
routing.controllers loader picked the controller up the moment it became a service, and the
endpoints are served at /command-logs. Importing the routes file cannot move them. That
import is read after the routes already exist, so a prefix set there is silently ignored — you
would get a 404 on the prefixed path while the real one stays open. Do not import anything.
If nothing is listed (Symfony 6.4 skeleton, or any application whose controller import is scoped
to App\Controller), import the routes yourself. Here the prefix does apply:
# config/routes/command_logger.yaml command_logger: resource: '@CommandLoggerBundle/config/routes.yaml' prefix: /api
3. Protect the path debug:router printed, not the one you expected. Guarding
^/api/command-logs while the routes are served at /command-logs leaves the API wide open and
looks protected, which is worse than leaving it visibly unprotected:
# config/packages/security.yaml security: access_control: # Match this against `debug:router` output. Without an import, that is /command-logs; # with one on a 6.4-style skeleton, it is your prefix + /command-logs. - { path: ^/command-logs, roles: ROLE_ADMIN }
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/command-logs |
Paginated, filterable collection of command logs (JSON-LD Hydra collection). |
GET |
/command-logs/stats |
Aggregate statistics (summary, exit code breakdown, per-command breakdown) over the same filterable set of logs. |
GET |
/command-logs/{id} |
A single command log, looked up by its numeric id or its executionToken. |
Route names are prefixed with command_logger_api_ (e.g. command_logger_api_list,
command_logger_api_stats, command_logger_api_item), following Symfony's naming convention for
bundle-provided routes.
Like the rest of this REST API, /command-logs/stats exposes exactly the same aggregated data as
the command-logger:stats CLI command and the log list above — it stays behind the same
command_logger.api.enabled flag and the same security warning at the top of this section: put
your own access_control rule in place before enabling it.
Query parameters
The parameters below apply to both GET /command-logs and GET /command-logs/stats, and can be
combined, unless stated otherwise. On /command-logs/stats, page is not used (there is nothing
to paginate) and limit instead caps how many commands appear in the per-command breakdown.
page(optional, integer, default1) — Page number to return; must be positive. Ignored by/stats.limit(optional, integer, default10) — Number of entries per page; must be between1and100. On/stats, the number of commands returned in the per-command breakdown.name(optional, string, minimum length2) — Filters logs whose command name contains this value.status(optional, string, one ofsuccessorerror) — Filters by outcome. Cannot be combined withcode.code(optional, integer) — Filters by exact exit code. Cannot be combined withstatus.from(optional, string,Y-m-dorY-m-d H:i:s) — Only logs started on or after this date/time.to(optional, string,Y-m-dorY-m-d H:i:s) — Only logs started on or before this date/time. Must not be earlier thanfrom.
Invalid or conflicting parameters return 422 Unprocessable Entity with a
Problem Details (application/problem+json) body listing
the offending violations, each with its propertyPath. Unknown ids return 404 Not Found in the
same Problem Details format.
Statistics response
GET /command-logs/stats wraps the same figures as the command-logger:stats CLI command in a
JSON-LD envelope: a summary (volume by outcome, failure rate, duration extrema), a byExitCode
breakdown, and a byCommand breakdown bounded by limit.
{
"@context": "/contexts/CommandLogStatistics",
"@id": "/command-logs/stats",
"@type": "CommandLogStatistics",
"summary": {
"total": 42,
"successCount": 39,
"failureCount": 3,
"unfinishedCount": 0,
"failureRate": 0.0714,
"durationMs": {
"avg": 182.4,
"min": 12,
"max": 950,
"count": 42
}
},
"byExitCode": {
"0": 39,
"1": 3
},
"byCommand": [
{
"commandName": "app:example",
"total": 20,
"successCount": 19,
"failureCount": 1,
"unfinishedCount": 0,
"failureRate": 0.05,
"durationMs": { "avg": 150.2, "min": 20, "max": 400, "count": 20 }
}
]
}
License
MIT License