Search by

ayaou / command-logger-bundle

ayaou

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

Statistics

Installs: 99

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v1.11.0 2026-09-04 09:35 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 command
  • arguments – Command arguments in JSON format
  • startTime – Execution start time
  • endTime – Execution end time
  • exitCode – Command exit code
  • durationMs – Execution duration in milliseconds, or null when unknown (see "Command Execution Statistics" below)
  • errorMessage – Error message if applicable
  • output – What the command printed, or null unless 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. Since command_log is 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 OutputInterface are captured - including everything SymfonyStyle produces.
  • echo, print and 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 - NullOutput and BufferedOutput capture nothing, and are not disturbed.
  • A command killed before it terminates leaves output null, exactly as it leaves endTime and exitCode null: 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:show and 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-d or Y-m-d H:i:s): Only include logs started on or after this date/time.
  • --to (optional, Y-m-d or Y-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 durationMs column is new. After upgrading, run doctrine: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 keep durationMs = null forever; 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 the name query 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): success or error. Cannot be combined with --code.
  • --code|-c (optional): Filters by a specific exit code.
  • --from (optional, Y-m-d or Y-m-d H:i:s): Only include logs started on or after this date/time.
  • --to (optional, Y-m-d or Y-m-d H:i:s): Only include logs started on or before this date/time.
  • --limit|-l (optional, default 10): 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 reached console.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 rateFailures / 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 durationMs value. Unfinished logs and logs predating the schema update are excluded from these figures rather than treated as 0.
  • Measured executions – How many of the matched logs contributed a durationMs value 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 --limit entries.

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_control in security.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.enabled may be all it takes to expose the routes. On a Symfony 7.4 or 8.x application (any skeleton generated with the default config/routes.yaml, which imports resource: routing.controllers), that loader automatically routes the #[Route] attributes of every controller registered as a service — including this bundle's, the moment api.enabled turns it into one. The routes then appear in debug:router with no import of config/routes.yaml required at all. Symfony 6.4 skeletons scope that same loader to App\Controller only, so there the explicit import below is still what exposes the API. Either way, put the access_control rule in place before setting api.enabled: true in 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, default 1) — Page number to return; must be positive. Ignored by /stats.
  • limit (optional, integer, default 10) — Number of entries per page; must be between 1 and 100. On /stats, the number of commands returned in the per-command breakdown.
  • name (optional, string, minimum length 2) — Filters logs whose command name contains this value.
  • status (optional, string, one of success or error) — Filters by outcome. Cannot be combined with code.
  • code (optional, integer) — Filters by exact exit code. Cannot be combined with status.
  • from (optional, string, Y-m-d or Y-m-d H:i:s) — Only logs started on or after this date/time.
  • to (optional, string, Y-m-d or Y-m-d H:i:s) — Only logs started on or before this date/time. Must not be earlier than from.

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