Monitoring
The signals that matter in a Magento store - indexers, queue depth, cron, errors - and how to write an alert someone can act on.
bin/magento indexer:status [indexer_name ...]Monitoring a Magento store is not CPU graphs. The failures that cost money are quiet: an indexer went invalid and prices are stale, a consumer died and order confirmation emails stopped, cron stopped and nothing is being scheduled at all. Five signals cover most of it.
The five signals
- Indexer status.
bin/magento indexer:statusreports each indexer as Ready, Reindex required, Processing or Suspended. Reindex required for more than a few minutes is the alarm. - The mview backlog. mview is the mechanism that watches a changelog table and feeds changed row ids to an indexer set to Update by Schedule. The same command's schedule column counts rows still pending; a number that only grows means indexing is behind.
- Queue depth. With RabbitMQ the broker reports message counts. On the MySQL queue, rows in
queue_message_statuswithstatus2 are waiting and 6 have errored. - Cron. No new rows in
cron_schedulemeans cron is not running at all. Rows with statuserrorcarry the reason inmessages. - Error rate. New files in
var/report/, entries invar/log/exception.log, and 5xx responses at the balancer.
An alert that names an action
"Indexer unhealthy" wakes someone who then has to work out what to do. "catalog_product_price invalid for 15 minutes - run bin/magento indexer:reindex catalog_product_price" does not. Magento Open Source also ships Magento_NewRelicReporting; enable it with bin/magento config:set newrelicreporting/general/enable 1 and then bin/magento cache:flush. New Relic itself is a paid service.
Example
<?php
// app/code/Vendor/Ops/Console/Command/HealthCommand.php
declare(strict_types=1);
namespace Vendor\Ops\Console\Command;
use Magento\Cron\Model\ResourceModel\Schedule\CollectionFactory as ScheduleCollectionFactory;
use Magento\Cron\Model\Schedule;
use Magento\Framework\Console\Cli;
use Magento\Framework\Indexer\ConfigInterface;
use Magento\Framework\Indexer\IndexerRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class HealthCommand extends Command
{
public function __construct(
private readonly ConfigInterface $indexerConfig,
private readonly IndexerRegistry $indexerRegistry,
private readonly ScheduleCollectionFactory $scheduleCollectionFactory,
?string $name = null
) {
parent::__construct($name);
}
protected function configure(): void
{
$this->setName('vendor:ops:health');
$this->setDescription('Report invalid indexers and failed cron jobs');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$invalid = [];
foreach (array_keys($this->indexerConfig->getIndexers()) as $indexerId) {
if ($this->indexerRegistry->get($indexerId)->isInvalid()) {
$invalid[] = $indexerId;
}
}
$errors = $this->scheduleCollectionFactory->create()
->addFieldToFilter('status', Schedule::STATUS_ERROR)
->getSize();
$output->writeln('invalid_indexers=' . count($invalid)
. ' (' . (implode(',', $invalid) ?: 'none') . ')');
$output->writeln('cron_errors=' . $errors);
return $invalid === [] && $errors === 0
? Cli::RETURN_SUCCESS
: Cli::RETURN_FAILURE;
}
}
// $ bin/magento vendor:ops:health
// invalid_indexers=1 (catalog_product_price)
// cron_errors=0
// $ echo $?
// 1When to use it
- Add a Magento store to an existing alerting stack without guessing at metrics.
- Catch stale prices before a customer or a client does.
- Prove whether cron is running on a server you have just inherited.
- Turn a vague 'site is slow' report into a named service and a command.
More examples
Registering the command so a monitoring agent can call it
A console command only exists once it is added to Magento\Framework\Console\CommandList, and this di.xml lives in etc/ rather than etc/adminhtml because the CLI runs in the global area. Apply it with bin/magento setup:di:compile followed by bin/magento cache:flush; the command then appears in bin/magento list. The exit code is the part a monitoring agent reads, so keep the output one metric per line.
<!-- app/code/Vendor/Ops/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="vendor_ops_health" xsi:type="object">
Vendor\Ops\Console\Command\HealthCommand
</item>
</argument>
</arguments>
</type>
</config>The same constructor, shorthand and longhand
Promotion is a shorthand for declaring a property and assigning the argument to it; the object manager injects the same dependencies either way, and di.xml arguments work unchanged. You will meet the longhand in every core class you read and the shorthand in most recent module code, so recognise both. Neither form makes the class injectable on its own: the type must be an interface or a concrete class Magento can construct.
<?php
// Two ways to write one constructor. They compile to the same object.
declare(strict_types=1);
// Shorthand: promoted constructor properties (PHP 8.0 and later, readonly 8.1+).
public function __construct(
private readonly ConfigInterface $indexerConfig,
private readonly IndexerRegistry $indexerRegistry,
?string $name = null
) {
parent::__construct($name);
}
// Longhand: declare, then assign. Most of the Magento core is written this way.
private readonly ConfigInterface $indexerConfig;
private readonly IndexerRegistry $indexerRegistry;
public function __construct(
ConfigInterface $indexerConfig,
IndexerRegistry $indexerRegistry,
?string $name = null
) {
$this->indexerConfig = $indexerConfig;
$this->indexerRegistry = $indexerRegistry;
parent::__construct($name);
}Reading the same signals straight from the database
These are the tables the CLI commands read, which makes them the shortest path to a dashboard panel or a scripted check. The queue_message_status rows exist only where the MySQL queue is in use; with RabbitMQ the depth lives in the broker and rabbitmqctl list_queues reports it. cron_schedule is pruned by Magento's own history cleanup, so the counts are naturally recent.
-- Run these against a replica. Read-only, safe during an incident.
-- Indexers: anything not 'valid' is serving stale data.
SELECT indexer_id, status, updated FROM indexer_state ORDER BY status DESC;
-- mview: a version_id far behind the changelog means indexing is behind.
SELECT view_id, mode, status, version_id, updated FROM mview_state;
-- MySQL queue depth. 2 = new, 3 = in progress, 4 = complete, 5 = retry, 6 = error.
SELECT status, COUNT(*) AS messages
FROM queue_message_status
GROUP BY status;
-- Cron in the last hour. No rows at all means cron is not running.
SELECT job_code, status, COUNT(*) AS runs, MAX(finished_at) AS last_finished
FROM cron_schedule
WHERE scheduled_at > NOW() - INTERVAL 1 HOUR
GROUP BY job_code, status
ORDER BY runs DESC;
Discussion