
WooCommerce relies on a lot of work happening in the background. Some of that is handled by WordPress cron, while other tasks are queued through Action Scheduler.
This can include things like sending scheduled emails, processing subscription renewals, delivering webhooks, running scheduled WooCommerce actions, and other maintenance tasks that do not happen during a normal page request.
The problem is that when one of these background jobs stops running, it is not always obvious straight away. A scheduled action can fail, a queue can become backed up, or a cron task can simply stop firing. In many cases, the first sign of a problem is a customer reporting that something did not happen when expected.
That Is Where Monitoring Becomes Useful
With AppSignal, we can monitor these background processes and get visibility into whether they are running successfully, failing, taking too long, or missing their expected schedule altogether.
In this article, we will build a small WooCommerce background job, run it through Action Scheduler, and use AppSignal to detect successful executions, failures and missed runs. We will then go deeper by checking queue-health conditions for a monitored Action Scheduler group from PHP and sending Process Monitor events directly from WordPress. The examples were tested locally with Lando, WooCommerce 10.8.1, Action Scheduler 3.9.3 and PHP 8.4.15.
What You'll Need
- A WooCommerce store you can safely test on (local or staging), with shell access and WP-CLI.
- A free AppSignal account with an app for the store, and that app's Push API key.
- AppSignal Wrap installed wherever the monitored commands run.
How WooCommerce Runs Background Jobs
WooCommerce background work commonly involves two important systems: WP-Cron and Action Scheduler. They solve slightly different problems, but both are important for keeping scheduled and queued tasks moving.
WP-Cron
WP-Cron is WordPress’s built-in scheduling system. Plugins and themes can use it to schedule tasks that should run later or on a recurring basis.
Unlike a traditional system cron, WP-Cron is normally triggered when someone visits the site. On a busy store, that can work reasonably well because requests happen regularly. On a low-traffic or irregular-traffic site, however, scheduled tasks may run later than expected because there may be no request available to trigger them.
For WooCommerce, that matters because some background work can be time-sensitive. If cron is delayed or stops being triggered altogether, scheduled tasks can begin to build up without there being an obvious problem on the storefront.
Action Scheduler
WooCommerce also uses Action Scheduler, a job queue designed for scheduled and background processing in WordPress.
It is used extensively by WooCommerce and many of its extensions for tasks such as subscription renewals, webhook delivery, scheduled emails, database updates, and other work that should happen outside the normal page request.
Each scheduled action has its own status, such as pending, complete or failed. This makes Action Scheduler much easier to inspect than a simple cron event, but it does not mean problems cannot go unnoticed.
By default, Action Scheduler is itself kicked off through WordPress. Its queue runner is scheduled through WP-Cron, and it can also initiate an asynchronous loopback request from the shutdown hook on WordPress admin requests when pending actions are available. This means Action Scheduler gives WooCommerce a more capable queue, but the default runner can still depend on WordPress scheduling and HTTP loopback behaviour.
Actions can fail because of an exception or application error, become overdue because the runner is not processing the queue, or remain in progress longer than expected. Over time, this can lead to a backlog of scheduled work even while the main WooCommerce store continues to appear healthy.
That is why it is useful to monitor both whether the background runner is executing and whether the jobs inside it are actually completing successfully.
Creating a Safe Test Job
For this tutorial, we will use a small custom WooCommerce background job built with Action Scheduler.
The goal is to use something realistic enough to behave like a normal WooCommerce background task, while avoiding anything that could affect orders, payments, customers, webhooks, or other important store data.
The job reads a maximum of 25 published WooCommerce products, checks their stock status, and stores only an aggregate summary. It records how many products are in stock, out of stock, or on backorder.
It also includes a deliberate failure mode, which gives us a safe way to test how monitoring behaves when a background job fails.
Registering the Action Scheduler Hook and Building the Product Summary
For this tutorial, save the demo job as a must-use plugin at wp-content/mu-plugins/appsignal-woocommerce-demo.php.
Start the file with the PHP opening tag, then register the callback that Action Scheduler will run. When Action Scheduler runs an action with the appsignal_demo_catalog_summary hook, WordPress will call the matching function.
The callback accepts a mode argument so we can safely test both successful and failed executions:
<?php
add_action(
'appsignal_demo_catalog_summary',
'appsignal_demo_catalog_summary',
10,
1
);
function appsignal_demo_catalog_summary( $mode = 'success' ) {
if ( 'fail' === $mode ) {
throw new RuntimeException(
'Deliberate AppSignal catalog-summary failure.'
);
}
if ( ! function_exists( 'wc_get_products' ) ) {
throw new RuntimeException(
'WooCommerce must be active to build the catalog summary.'
);
}
$products = wc_get_products(
array(
'status' => 'publish',
'limit' => 25,
'orderby' => 'ID',
'order' => 'ASC',
'return' => 'objects',
)
);
$summary = array(
'products_checked' => count( $products ),
'instock' => 0,
'outofstock' => 0,
'onbackorder' => 0,
'other' => 0,
);
foreach ( $products as $product ) {
$status = $product->get_stock_status();
if (
in_array(
$status,
array( 'instock', 'outofstock', 'onbackorder' ),
true
)
) {
++$summary[ $status ];
} else {
++$summary['other'];
}
}
update_option(
'appsignal_demo_catalog_summary',
$summary,
false
);
}The query is deliberately limited to 25 published products so the test remains predictable and lightweight.
Nothing is changed on the products themselves. The job only reads their stock status and stores the resulting counts in a single WordPress option.
In our test store, a successful run produced:
{
"products_checked": 25,
"instock": 25,
"outofstock": 0,
"onbackorder": 0,
"other": 0
}By using a small custom Action Scheduler job like this, we can safely repeat the same test while we add process monitoring and compare successful and failed executions.
Running the Job Manually
Before adding AppSignal monitoring, it is worth running the job manually and confirming that the Action Scheduler behaves as expected.
We can create a fresh action using WP-CLI:
lando wp action-scheduler action create \
appsignal_demo_catalog_summary '+1 day' \
--group=appsignal-demo \
--args='["success"]'This schedules our appsignal_demo_catalog_summary action in the dedicated appsignal-demo group and passes success as its mode.
The command returns the ID of the newly created action:
Success: Single action (ACTION_ID) scheduled.We deliberately schedule the action for the following day. This prevents the normal Action Scheduler queue from picking it up before we are ready to run our controlled test.
Making Exit Codes Reliable
Before we run the action, there's one detail that becomes important once we monitor the job from outside WordPress: the process exit code.
Action Scheduler can correctly mark an individual action as failed without the parent CLI command necessarily behaving like a failed system process. That creates a problem for external process monitoring.
AppSignal Wrap monitors the command being executed. If that command exits successfully, the process can appear healthy even when the WooCommerce action inside it has failed.
For reliable monitoring, we therefore want the exit code of the CLI process to reflect the final state of the Action Scheduler job itself.
A conventional command-line process uses:
0for success- a non-zero value for failure
To make that behaviour explicit, we'll run the demo action through a small strict runner.
The runner also refuses to touch anything outside our demo hook and group, so a tutorial command can't accidentally execute an unrelated WooCommerce action.
Creating the Strict Runner
Save the strict runner as tools/appsignal/run-action.php. It validates the supplied action ID, makes sure it belongs to our tutorial hook and group, executes only that action, then checks the final Action Scheduler status so the process exit code reflects the real result:
<?php
/**
* Usage:
* lando wp eval-file tools/appsignal/run-action.php ACTION_ID
*
* Runs one pending tutorial action; never drains or cleans the queue.
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
exit( 1 );
}
if (
count( $args ) !== 1
|| ! ctype_digit( (string) $args[0] )
|| (int) $args[0] < 1
) {
WP_CLI::error(
'Provide exactly one positive Action Scheduler action ID.'
);
}
$action_id = (int) $args[0];
try {
if (
! class_exists( 'ActionScheduler' )
|| ! ActionScheduler::is_initialized()
) {
throw new RuntimeException(
'Action Scheduler is not available.'
);
}
$store = ActionScheduler::store();
$action = $store->fetch_action( $action_id );
if (
'appsignal_demo_catalog_summary'
!== $action->get_hook()
) {
throw new RuntimeException(
'Refusing to run an action outside the catalog-summary demo hook.'
);
}
if ( 'appsignal-demo' !== $action->get_group() ) {
throw new RuntimeException(
'Refusing to run an action outside the appsignal-demo group.'
);
}
$status = $store->get_status( $action_id );
if (
ActionScheduler_Store::STATUS_PENDING
!== $status
) {
throw new RuntimeException(
"Action {$action_id} is {$status}; expected pending."
);
}
$failure_message = null;
$on_failure = static function (
$failed_id,
$exception
) use (
$action_id,
&$failure_message
) {
if ( (int) $failed_id === $action_id ) {
$failure_message = $exception->getMessage();
}
};
add_action(
'action_scheduler_failed_execution',
$on_failure,
10,
2
);
try {
WP_CLI::log(
"Running demo action {$action_id}."
);
ActionScheduler::runner()->process_action(
$action_id,
'AppSignal tutorial CLI'
);
} finally {
remove_action(
'action_scheduler_failed_execution',
$on_failure,
10
);
}
$status = $store->get_status( $action_id );
if (
ActionScheduler_Store::STATUS_COMPLETE
!== $status
) {
$detail = $failure_message
? " {$failure_message}"
: '';
throw new RuntimeException(
"Action {$action_id} ended with status {$status}.{$detail}"
);
}
WP_CLI::success(
"Action {$action_id} completed."
);
} catch ( Throwable $exception ) {
WP_CLI::error(
$exception->getMessage()
);
}If the action completes successfully, the runner reaches WP_CLI::success() and the process exits successfully.
If the action ends in any other state, such as failed, the runner throws an exception. The surrounding error handler passes that message to WP_CLI::error(), which terminates the command with a non-zero exit code.
This gives us the behaviour we want:
- Action Scheduler status
complete→ process exits0 - Action Scheduler status
failed→ process exits non-zero
That gives AppSignal a much more trustworthy signal. A successful process now means that the WooCommerce background job was actually completed successfully, rather than simply meaning that the CLI command finished executing.
Running the Action
Run the action through the strict runner, replacing ACTION_ID with the ID returned when you created it:
lando wp eval-file tools/appsignal/run-action.php ACTION_IDA successful execution produces output similar to:
Running demo action ACTION_ID.
Success: Action ACTION_ID completed.We can also check the exit code returned by the runner:
echo $?A successful action should return:
0Checking the Action Scheduler Status
Finally, we can inspect the action directly using WP-CLI:
lando wp action-scheduler action get ACTION_ID --format=jsonFor a successful run, the important value is:
"status": "complete"You can also confirm the result in WooCommerce → Status → Scheduled Actions, where the action should appear with a status of Complete.

At this point, the job runs and the runner's exit code matches the result. Next, let's test both outcomes side by side.
Testing Success and Failure
With the demo job and strict runner in place, we can now test both outcomes and confirm that the Action Scheduler status matches the process exit code.
This is important because AppSignal will monitor the process from outside WordPress. If the exit code says the command succeeded when the underlying WooCommerce action actually failed, the monitor could report a false success.
Successful Run
First, create a fresh action in success mode:
lando wp action-scheduler action create \
appsignal_demo_catalog_summary '+1 day' \
--group=appsignal-demo \
--args='["success"]'In this test, Action Scheduler created action 239530.
Running it through the strict runner:
lando wp eval-file tools/appsignal/run-action.php 239530
echo $?The output was:
Running demo action 239530.
Success: Action 239530 completed.
0The final 0 confirms that the CLI process exited successfully.
Checking the action in WooCommerce also showed the final Action Scheduler status as complete.

Failed Run
Next, create a fresh action in failure mode:
lando wp action-scheduler action create \
appsignal_demo_catalog_summary '+1 day' \
--group=appsignal-demo \
--args='["fail"]'For this test, Action Scheduler created action 239531.
Running it through the same strict runner:
lando wp eval-file tools/appsignal/run-action.php 239531
echo $?This time the output was:
Running demo action 239531.
Error: Action 239531 ended with status failed. Deliberate AppSignal catalog-summary failure.
1The runner returned exit code 1, and WooCommerce recorded the action with a status of failed.

These two tests give us the behaviour we need:
- successful action →
complete→ exit code0 - failed action →
failed→ exit code1
That means the process result now accurately reflects the WooCommerce job result, giving AppSignal a reliable signal to monitor in the next stage.
Monitoring the Runner with AppSignal Wrap
Now that the runner returns a trustworthy exit code, we can start monitoring it with AppSignal Wrap.
AppSignal Wrap sits around a command-line process and reports its execution to AppSignal. This makes it useful for jobs that run outside a normal web request, such as cron commands, queue workers, maintenance scripts, or in this case, our WooCommerce Action Scheduler runner.
For this tutorial, Wrap is installed inside the same Lando appserver that runs WordPress. The AppSignal push key is provided through an environment variable rather than being included directly in the command or committed to the project.
Before running the command, create a Cron Process Monitor in AppSignal (how to create one) using the identifier:
woo-catalog-summaryFor the initial manual tests, the monitor was configured to expect a run every minute with a maximum duration of two minutes. This short schedule is only useful while testing because it lets us see successful and missed occurrences quickly. In production, the schedule should match how often the real background process is expected to run.
It is worth noting that the Process Monitor does not schedule the job itself. It defines what AppSignal expects to happen. The command still needs to be triggered separately by cron, a scheduler, or manually as we are doing here.
Once connected, AppSignal can tell us several useful things about the runner:
- whether the process actually started
- whether it completed successfully
- how long the execution took
- whether an expected execution never completed
That gives us visibility beyond simply knowing that an Action Scheduler entry exists inside WordPress.
Installing and Configuring AppSignal Wrap
AppSignal Wrap needs to be installed in the environment where the monitored command runs. AppSignal provides installation instructions for Linux and macOS in the Wrap documentation.
Wrap also needs the app-level Push API key for the AppSignal application and environment being monitored. You can find this under Settings → Push & Deploy in AppSignal.
Rather than placing the key in the command itself, expose it as the APPSIGNAL_APP_PUSH_API_KEY environment variable.
In this Lando setup, the variable is defined in a private, ignored local configuration and exposed to the appserver container. Because Wrap runs inside that container, it inherits the same environment variable.
The docker exec and Lando-specific paths used below are specific to this local environment. On a normal VPS or SSH host, you would run appsignal-wrap and WP-CLI directly from the WordPress installation.
Running the Strict Runner Through Wrap
We can run our existing strict runner through AppSignal Wrap without changing the PHP code itself.
The tested command is:
docker exec --user www-data --workdir /app \
your-wordpress-appserver \
appsignal-wrap woo-catalog-summary --cron -- \
wp eval-file tools/appsignal/run-action.php ACTION_IDReplace ACTION_ID with the ID of the Action Scheduler job you want to execute, and your-wordpress-appserver with the name of the container running WordPress in your environment.
appsignal-wrap starts the monitored process, while woo-catalog-summary matches the identifier of the Process Monitor we created in AppSignal.
The --cron option tells Wrap that this is a scheduled process. Everything after the final -- is the command being monitored, which in our case is the same strict WP-CLI runner we tested earlier.
For a successful action, the runner returns exit code 0. Wrap records the successful execution and sends the completed occurrence to AppSignal.
For a failed action, the runner returns exit code 1. Wrap reports the non-zero exit as an error and does not send a successful finish event. AppSignal can then show both the command failure and the missing successful completion of that scheduled occurrence.
This is where the strict exit handling from earlier becomes important: Wrap does not need to understand Action Scheduler itself. It only needs the command to communicate success or failure accurately.
Running the Monitor on a Real Schedule
So far, we have executed individual actions manually so each test remains controlled. The strict runner is deliberately built around a single Action Scheduler ID, so it is useful for testing but is not suitable as a recurring production runner.
To test a more realistic setup, register the same demo hook as a recurring Action Scheduler action every five minutes. Only register it once: re-running the command creates another recurrence.
lando wp action-scheduler action create \
appsignal_demo_catalog_summary \
'2026-10-08 11:50:00 UTC' \
--cron='*/5 * * * *' \
--group=appsignal-demo \
--args='["success"]'The start time above came from our test environment. When reproducing this, choose a future five-minute boundary rather than copying that timestamp.
Each execution creates a new Action Scheduler action, so the recurring job is not tied to a single action ID.
Something still has to process the queue every five minutes. For that, we'll use a host-level cron entry that calls a small shell script, plus a WP-CLI helper that keeps the queue runner scoped to our demo.
Scoping the CLI Queue Runner
Save the WP-CLI helper as tools/appsignal/demo-queue-cli.php. It keeps this tutorial run isolated to the modern Action Scheduler store and disables the runner’s global housekeeping for this CLI invocation:
<?php
/** WP-CLI-only isolation for the article's scoped queue command. */
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
exit( 1 );
}
// This site uses a hybrid migration store. The demo lives in modern tables;
// select that store only for this CLI invocation, avoiding legacy migration.
WP_CLI::add_wp_hook(
'action_scheduler_store_class',
static function () {
return 'ActionScheduler_DBStore';
},
PHP_INT_MAX
);
WP_CLI::add_hook(
'after_wp_load',
static function () {
// --group scopes execution, but the standard runner's cleaner is global.
add_filter(
'action_scheduler_default_cleaner_statuses',
'__return_empty_array',
PHP_INT_MAX
);
add_filter(
'action_scheduler_timeout_period',
static function () {
return -1;
},
PHP_INT_MAX
);
add_filter(
'action_scheduler_failure_period',
static function () {
return -1;
},
PHP_INT_MAX
);
}
);The --group and --hooks options we'll pass to the runner restrict which actions it processes, but they don't scope the runner's housekeeping. The helper prevents the standard runner from performing unrelated cleanup or timeout/failure housekeeping during this tutorial command.
It is deliberately scoped to this test environment rather than being a general Action Scheduler configuration change.
Running the Recurring Queue Command
Save the cron entry point as tools/appsignal/run-recurring-demo.sh:
#!/bin/sh
# Host cron entry point.
# The private key is inherited inside the Lando container.
set -eu
exec /usr/local/bin/docker \
--context desktop-linux \
exec \
--user www-data \
--workdir /app \
your-wordpress-appserver \
/usr/bin/flock -n /tmp/appsignal-demo-queue.lock \
/usr/local/bin/appsignal-wrap \
woo-catalog-summary \
--cron \
-- \
/usr/local/bin/wp \
--require=/app/tools/appsignal/demo-queue-cli.php \
action-scheduler run \
--group=appsignal-demo \
--hooks=appsignal_demo_catalog_summary \
--batch-size=10 \
--batches=1Replace your-wordpress-appserver with the name of the container running WordPress in your environment. A few details are worth calling out:
flockprevents overlapping executions if one run is still active when the next five-minute window arrives.--groupand--hooksrestrict processing to theappsignal-demogroup and ourappsignal_demo_catalog_summaryhook, so the command does not process unrelated WooCommerce jobs.--requireloads thedemo-queue-cli.phphelper from the previous section.- The script contains no AppSignal API key.
APPSIGNAL_APP_PUSH_API_KEYis supplied to the Lando appserver through the private.lando.local.ymlconfiguration, and becausedocker execruns the command inside that container, Wrap inherits it there.
Next, trigger the script from cron. On macOS, open the user crontab with:
crontab -eThen add:
*/5 * * * * /bin/sh /Users/your-user/path/to/project/tools/appsignal/run-recurring-demo.sh >> /tmp/appsignal-recurring-demo.log 2>&1Replace /Users/your-user/path/to/project with the path to your own project. On a production server, the same script would normally be invoked by the server’s own cron or scheduled-task service using that environment’s paths. Because this example runs locally, the Mac must remain awake and Docker and Lando must be running for the scheduled command to execute.
Finally, update the woo-catalog-summary Process Monitor in AppSignal to the matching schedule, */5 * * * *, with a maximum duration of two minutes.
During testing, consecutive recurring actions completed successfully five minutes apart. The WooCommerce and AppSignal screenshots below come from separate verified recurring runs, but both show the same five-minute setup: Action Scheduler creates new demo actions on the configured recurrence, while AppSignal records the wrapped runner executions against the matching monitor schedule.


The AppSignal schedule and the actual cron trigger now match, giving the monitor a predictable execution window instead of relying on manual commands.
A successful queue-runner process does not necessarily prove that every individual action inside a larger queue succeeded. That is why we add a separate queue-health check later in the article rather than relying on the runner exit code alone.
Cleaning Up the Recurring Test
After testing, remove the host cron entry with:
crontab -eDelete the run-recurring-demo.sh line so the local runner is no longer triggered every five minutes.
The recurring Action Scheduler entry should also be removed or cancelled so it does not continue creating demo actions after the tutorial is complete.
Finally, remove or adjust the woo-catalog-summary Process Monitor if the five-minute test schedule is no longer required.
For the deliberate missed-run test later in the article, we temporarily disabled the host cron trigger for one scheduled window, then restored it afterwards.
Viewing the Process in AppSignal
Once the wrapped command has run, the result appears under Process Monitoring in AppSignal.
Open the woo-catalog-summary monitor to view its individual occurrences. Each occurrence represents one expected execution of the scheduled process and shows whether AppSignal received the events it expected within the configured time window.
For our successful test, action 239535 completed normally and the strict runner returned exit code 0. AppSignal recorded the occurrence as Success, with a duration of around one second.

This gives us confirmation from outside WordPress that the runner did not just start, but also completed successfully.
Viewing the Runner Logs
AppSignal Wrap also captures the wrapped command’s standard output and standard error.
In this test, the Action Scheduler runner output appeared in AppSignal Logging under the application source and the woo-catalog-summary log group.
The logs show the scheduled task being found, processed and completed, giving us the command output alongside the Process Monitor occurrence.

Viewing a Failed Execution
The deliberate failure behaves differently.
Action 239534 threw our test exception, Action Scheduler marked it as failed, and the strict runner returned exit code 1.
Because Wrap received a non-zero exit code, AppSignal created a NonZeroExit error for the woo-catalog-summary process.
You can find this under Errors in AppSignal, where the failure provides a separate signal that the command itself exited unsuccessfully.

The Process Monitor also tells us something slightly different about the same failure.
Wrap sends a start event when the process begins, but it only sends a successful finish event when the wrapped command completes successfully. Because our deliberately failed command exited with 1, no successful finish event was sent.
After the configured two-minute deadline passed, AppSignal therefore marked that Process Monitor occurrence as Missed.

This gives us two useful views of the failure:
- Errors → NonZeroExit tells us that the command exited unsuccessfully.
- Process Monitoring → Missed tells us that the expected process never reported a successful completion.
Together, these make it much easier to distinguish between a job that completed normally and one that started but failed before it could finish successfully.
Timing Matters
During testing, we also ran a successful action outside the Process Monitor’s configured schedule window, which AppSignal recorded as Unexpected.
That is useful behaviour to understand. A Process Monitor does not simply record whether a command has run at some point. It compares executions against the schedule you configured.
For this tutorial, we used an every-minute schedule purely to make testing faster. In a production store, the AppSignal monitor schedule should match the schedule of the background job itself. Otherwise, valid executions could appear as unexpected, or expected executions could be reported as missed.
Breaking It on Purpose
So far, we have seen what happens when a background job starts and then fails. Another important failure mode is when the job never starts at all.
To test this, we temporarily configured the woo-catalog-summary Process Monitor to expect a run every five minutes, with a maximum duration of two minutes.
For one scheduled window, we temporarily disabled the host cron entry that runs run-recurring-demo.sh. The recurring Action Scheduler setup remained in place, but the wrapped queue runner was not triggered for that window.
AppSignal therefore expected the process to start at 08:45 and finish by 08:47, but no events were received.
After the deadline passed, the occurrence was marked as Missed.

This is different from the deliberate failure test earlier.
In that case, the process started and then exited unsuccessfully. In this controlled test, we know the process never started because we deliberately disabled the host cron trigger for that execution window. In production, a Missed occurrence tells us that AppSignal did not receive the expected events. That could mean the scheduler or runner never executed, but reporting or connectivity problems can produce the same symptom.
That distinction is useful in production because the two situations can point to different problems:
- a failed process may indicate an application or code error
- a missed process may indicate that cron or the runner did not execute, or that AppSignal never received the expected monitoring events
By monitoring the expected schedule as well as the process exit status, AppSignal can help distinguish between a job that ran and failed and one that never ran in the first place.
Receiving the Alert
For this test, we also attached AppSignal’s default email notifier to the woo-catalog-summary Process Monitor.
After the expected 08:55 execution was deliberately skipped, AppSignal marked the occurrence as Missed once the two-minute maximum duration had passed.
The configured notifier then delivered an email alert shortly afterwards, confirming that the failure was not only visible in the AppSignal dashboard but was also sent to the configured notification channel.

Going Deeper from PHP
AppSignal Wrap tells us whether the background runner itself started, finished, and returned a successful exit code.
That is useful, but it does not necessarily tell us whether the Action Scheduler queue is healthy.
A runner can execute successfully while individual actions are failing, pending work is becoming overdue, or jobs are remaining in progress for too long.
To get that extra visibility, we can add a small PHP health check around the Action Scheduler itself.
For this tutorial, the health check is read-only and scoped to the appsignal-demo group. It does not retry, cancel, delete, or modify any actions.
The health check looks for three unhealthy conditions and also records the age of the oldest overdue action to show how far behind the queue has become.
This gives us a different type of monitoring.
The woo-catalog-summary monitor answers: Did the background runner complete successfully?
The woo-queue-health monitor answers: Does our monitored Action Scheduler group pass the queue-health checks?
Inspecting the Queue from PHP
Save the queue-health check as tools/appsignal/queue-health.php. The script is read-only and scopes every query to the appsignal-demo group:
<?php
/**
* Read-only snapshot of the tutorial queue.
*
* Usage:
* lando wp eval-file tools/appsignal/queue-health.php
* [WINDOW_MINUTES] [RUNNING_MINUTES]
*
* Defaults:
* 15-minute failure window and
* 5-minute running threshold.
*
* Exit codes:
* 0 healthy
* 1 unhealthy
* 2 invalid input or inspection error
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
exit( 2 );
}
try {
if ( count( $args ) > 2 ) {
throw new InvalidArgumentException(
'Provide at most WINDOW_MINUTES and RUNNING_MINUTES.'
);
}
foreach ( $args as $value ) {
if (
! ctype_digit( (string) $value )
|| (int) $value < 1
|| (int) $value > 525600
) {
throw new InvalidArgumentException(
'Thresholds must be whole minutes from 1 to 525600.'
);
}
}
if (
! class_exists( 'ActionScheduler' )
|| ! ActionScheduler::is_initialized()
) {
throw new RuntimeException(
'Action Scheduler is not available.'
);
}
$store = new ActionScheduler_DBStore();
$window = isset( $args[0] )
? (int) $args[0]
: 15;
$running_threshold = isset( $args[1] )
? (int) $args[1]
: 5;
$now = new DateTime(
'now',
new DateTimeZone( 'UTC' )
);
$recent = ( clone $now )->modify(
"-{$window} minutes"
);
$running_before = ( clone $now )->modify(
"-{$running_threshold} minutes"
);
$count = static function (
array $query
) use ( $store ) {
$result = $store->query_actions(
array_merge(
$query,
array(
'group' => 'appsignal-demo',
)
),
'count'
);
if ( ! is_numeric( $result ) ) {
throw new RuntimeException(
'Could not count actions.'
);
}
return (int) $result;
};
$failed = $count(
array(
'status' =>
ActionScheduler_Store::STATUS_FAILED,
'modified' => $recent,
'modified_compare' => '>=',
)
);
$overdue_query = array(
'status' =>
ActionScheduler_Store::STATUS_PENDING,
'date' => $now,
'date_compare' => '<',
);
$overdue = $count( $overdue_query );
$oldest = $store->query_actions(
array_merge(
$overdue_query,
array(
'group' => 'appsignal-demo',
'per_page' => 1,
'orderby' => 'date',
'order' => 'ASC',
)
)
);
if ( ! is_array( $oldest ) ) {
throw new RuntimeException(
'Could not inspect the oldest pending action.'
);
}
$age = $oldest
? max(
0,
$now->getTimestamp()
- $store->get_date(
$oldest[0]
)->getTimestamp()
)
: null;
$stale_running = $count(
array(
'status' =>
ActionScheduler_Store::STATUS_RUNNING,
'modified' => $running_before,
'modified_compare' => '<',
)
);
WP_CLI::log(
'Action Scheduler health | group: appsignal-demo | '
. $now->format( 'Y-m-d H:i:s' )
. ' UTC'
);
WP_CLI::log(
"Failed actions (last attempted within {$window}m): {$failed}"
);
WP_CLI::log(
"Overdue pending actions: {$overdue}"
);
WP_CLI::log(
'Oldest overdue pending age: '
. (
null === $age
? 'none'
: "{$age}s"
)
);
WP_CLI::log(
"In-progress actions older than {$running_threshold}m: {$stale_running}"
);
$unhealthy =
$failed > 0
|| $overdue > 0
|| $stale_running > 0;
WP_CLI::log(
$unhealthy
? 'UNHEALTHY'
: 'HEALTHY'
);
WP_CLI::halt(
$unhealthy ? 1 : 0
);
} catch ( Throwable $exception ) {
WP_CLI::error(
$exception->getMessage(),
false
);
WP_CLI::halt( 2 );
}This checks three unhealthy conditions within the appsignal-demo group: recent failures, overdue pending actions, and actions that have remained in progress beyond the configured threshold.
It also records the age of the oldest overdue action so we can see how far behind the monitored group has become.
The count helper deliberately checks that the Action Scheduler returned a numeric result. If the query itself fails, the script therefore reports an inspection error rather than silently converting the result to zero.
Checking Queue Health from PHP
The PHP health check runs through WP-CLI:
lando wp eval-file tools/appsignal/queue-health.php 1 5
echo $?A healthy result looks like this:
Action Scheduler health | group: appsignal-demo
Failed actions (last attempted within 1m): 0
Overdue pending actions: 0
Oldest overdue pending age: none
In-progress actions older than 5m: 0
HEALTHY
0
If one of the queue-health conditions is breached, the script returns a non-zero exit code instead.
For the unhealthy test, we ran the script with its default 15-minute failure window. That window still included the deliberately failed demo action:
Action Scheduler health | group: appsignal-demo
Failed actions (last attempted within 15m): 1
Overdue pending actions: 0
Oldest overdue pending age: none
In-progress actions older than 5m: 0
UNHEALTHY
1
That gives us a simple signal that can be monitored externally without changing any queue data.
Sending Queue Health to AppSignal
We can wrap the same PHP health check with AppSignal Wrap:
docker exec --user www-data --workdir /app \
your-wordpress-appserver \
appsignal-wrap woo-queue-health --cron -- \
wp eval-file tools/appsignal/queue-health.php
echo $?The woo-queue-health identifier is configured as a separate Process Monitor so it remains distinct from the background runner itself.
In testing, a healthy queue returned exit code 0 and AppSignal recorded the occurrence as Success.

The unhealthy check returned exit code 1. AppSignal recorded a NonZeroExit error, while the Process Monitor occurrence later became Missed because no successful finish event was received.

This means we now have two complementary layers of monitoring:
woo-catalog-summarymonitors whether the runner executes successfullywoo-queue-healthmonitors whether the queue passes our PHP health rules
Together, they provide a much clearer picture than either one alone.
A process can be running while the queue is unhealthy, and a healthy queue check can confirm that recent failures, overdue work, and long-running actions are not building up silently.
A Note on the Failure Window
The health check uses the action's last-attempt time to identify recent failures rather than an exact failure timestamp.
For the tutorial, the default observation window is 15 minutes. On a real store, this should be tuned to the workload and how frequently the queue is expected to process actions.
The check is also scoped to the modern Action Scheduler tables and the appsignal-demo group so historical failures elsewhere in the store do not distort the result.
Sending Process Monitor Events Directly from PHP
So far, PHP has evaluated the health rules for our monitored appsignal-demo group, while AppSignal Wrap has been responsible for reporting the result.
For jobs that already run inside WordPress, you can also send Process Monitor events directly from PHP using AppSignal’s HTTP API.
Before running this example, create a separate Cron Process Monitor in AppSignal using the identifier:
woo-php-healthFor this test, save the script as tools/appsignal/php-process-monitor.php.
The important part is that the start and finish events use the same unique digest, allowing AppSignal to correlate them as one occurrence.
<?php
/**
* Direct WordPress HTTP API example, without AppSignal Wrap.
*
* Run:
* lando wp eval-file tools/appsignal/php-process-monitor.php
*/
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
exit( 1 );
}
try {
$key = getenv( 'APPSIGNAL_APP_PUSH_API_KEY' );
if ( ! is_string( $key ) || '' === trim( $key ) ) {
throw new RuntimeException(
'APPSIGNAL_APP_PUSH_API_KEY is missing.'
);
}
$digest = wp_generate_uuid4();
$send = static function ( $start ) use ( $key, $digest ) {
$query = array(
'api_key' => $key,
'identifier' => 'woo-php-health',
'digest' => $digest,
);
if ( $start ) {
$query['kind'] = 'start';
}
$response = wp_remote_post(
add_query_arg(
$query,
'https://appsignal-endpoint.net/check_ins/cron'
),
array(
'timeout' => 10,
'redirection' => 0,
)
);
if ( is_wp_error( $response ) ) {
throw new RuntimeException(
'Process monitor HTTP request failed.'
);
}
$status = wp_remote_retrieve_response_code( $response );
if ( $status < 200 || $status >= 300 ) {
throw new RuntimeException(
"Process monitor returned HTTP {$status}."
);
}
WP_CLI::log(
( $start ? 'Start' : 'Finish' )
. " accepted (HTTP {$status}); digest: {$digest}"
);
};
$send( true );
// Stand-in for successful PHP work.
sleep( 2 );
$send( false );
WP_CLI::success(
'woo-php-health events sent directly from WordPress.'
);
} catch ( Throwable $exception ) {
WP_CLI::error(
$exception->getMessage()
);
}The first request sends the start event. After the simulated work completes, the second request sends the successful finish event by omitting the kind.
For this test, the two-second delay is only a stand-in for real PHP work so the occurrence has a visible duration.
Run the script directly through WP-CLI, without AppSignal Wrap:
lando wp eval-file tools/appsignal/php-process-monitor.phpBoth requests returned HTTP 200, and AppSignal correlated them into a single woo-php-health occurrence.
The resulting occurrence showed:
- Status: Success
- Actual duration: 2 seconds
- matching start and finish events
- a shared Remote ID for the occurrence
This demonstrates the difference between the two approaches used in this tutorial.
AppSignal Wrap observes the lifecycle of an external command.
Direct PHP events let WordPress code decide when a monitored operation starts and finishes.
Wrap is a good fit for cron commands, queue runners and other external processes, while direct PHP events are useful when the application has more precise knowledge of the work being performed.
Production Considerations
The examples in this article use a deliberately small test job and short monitoring windows so the behaviour is easy to see. On a production WooCommerce store, there are a few extra things worth considering.
Keep Credentials Out of Source Control
AppSignal credentials should not be hardcoded into PHP files, shell scripts, or committed configuration.
For this setup, the AppSignal push key is stored in a private environment configuration and exposed to the appserver as an environment variable.
That keeps the key out of the repository while still making it available to AppSignal Wrap at runtime.
Match the Monitor to the Real Schedule
The one-minute and five-minute schedules used in this tutorial were chosen to make testing quick.
In production, there is no universally correct interval. The Process Monitor schedule should match how often the real job is expected to run, whether that is every minute, every hour, or once a day.
The maximum duration should likewise reflect the normal runtime of that process while remaining tight enough to expose genuinely slow or stuck executions.
Avoid Noisy Monitoring
Not every WooCommerce background action needs its own monitor.
A busy store can have a large number of scheduled actions, and monitoring all of them individually would quickly create noise.
It is usually more useful to focus on processes that are important to the customer experience or business operation, such as:
- subscription renewals
- order-related emails
- webhook delivery
- imports or exports
- payment or fulfilment integrations
- other business-critical scheduled work
Queue-level health checks can then provide broader visibility without creating a separate alert for every action.
Choose Thresholds Carefully
A failed job does not always mean there is an incident.
Some actions may retry automatically, and a small number of temporary failures may be acceptable depending on the store.
The same applies to overdue or long-running actions. Thresholds should reflect the normal workload of the site rather than using arbitrary values.
A useful starting point is to observe the queue under normal conditions first, then set thresholds around what would genuinely require investigation.
Test Safely
Avoid deliberately breaking real orders, payments, subscriptions, webhooks, or customer-facing processes just to test monitoring.
A dedicated test action, like the product-summary job used in this tutorial, gives you a controlled way to trigger success and failure without affecting real store data.
Where possible, validate the monitoring setup on a staging or local environment before applying it to production.
Hosts Without Shell Access
The examples in this article assume access to WP-CLI, the command line, and a system scheduler.
Some managed WordPress hosts do not provide that level of access.
In those environments, you may need to rely on the host's own cron configuration, scheduled task interface, or trigger monitoring from PHP instead.
The underlying principle is the same: create a reliable signal for whether the background process ran and whether the queue itself remains healthy.
Keep the Two Layers Separate
It is useful to think of the monitoring setup as two separate layers:
- process monitoring tells you whether the background runner executed successfully
- queue-health monitoring tells you whether the monitored queue scope passes the configured health checks
Keeping those signals separate makes incidents easier to diagnose.
A healthy runner does not necessarily mean a healthy queue, and an unhealthy queue does not necessarily mean the scheduler itself has stopped running.
That distinction is what makes the combined approach useful on a real WooCommerce store.
Conclusion
WooCommerce background jobs can fail in several different ways. A job may start and throw an error, a runner may never execute at all, or the Action Scheduler queue may gradually become unhealthy while the storefront still appears to be working normally.
In this tutorial, we built a small WooCommerce background job and used AppSignal to monitor it at several levels.
AppSignal Wrap gave us visibility into whether the CLI process started, completed successfully, or returned a non-zero exit code. Process Monitoring then allowed us to detect missed runs when an expected job never started.
We also went a step further from PHP by checking queue-health conditions for our monitored Action Scheduler group and sending Process Monitor events directly from WordPress.
The important distinction is that these signals answer different questions:
- Did the runner execute successfully?
- Did the scheduled process run when expected?
- Does the monitored Action Scheduler group pass our health checks?
- Does the application itself know that an important piece of work started and finished?
Using those signals together makes silent failures much easier to spot before they turn into customer-facing problems.
For a production store, the exact jobs, schedules and thresholds will vary, but the principle stays the same: monitor both the process that runs the work and the state of the queue behind it.
Ready to monitor your WooCommerce background jobs? Create a free AppSignal account.
Published
Wondering what you can do next?
- Try out AppSignal with a 30-day free trial.
- Reach out to our support team with any feedback or questions.
- Share this article on social media

Sam Noble
Our guest author Sam is a WordPress and WooCommerce developer.
All articles by Sam NobleBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


