If you need to trace actions in the WordPress Action Scheduler, the hard part is finding out who scheduled them. Under WooCommerce > Status > Scheduled Actions (also at Tools > Scheduled Actions) you get the hook name, the arguments and a log that says “action created”. It doesn’t tell you which plugin, file or request queued the action. That’s a problem when a hook keeps filling the pending queue, runs twice, or comes back after you thought you had removed it.
The snippet below adds one extra line to the log of every action as it is stored. The line gives the file and line number of the call that scheduled the action, the function or method that made the call, and the request it happened in (front end, wp-admin, REST, admin-ajax, WP-Cron or WP-CLI) with the current user ID. You read it in the Log column of the Scheduled Actions screen you already use, and nothing else changes.
The snippet
Put it in a small plugin, an mu-plugin or a Scripts Organizer PHP block. It needs Action Scheduler, which WooCommerce bundles. Without Action Scheduler the hook never fires and the snippet does nothing.
<?php
/**
* Plugin Name: DPC Action Scheduler Trace
* Description: Logs where each Action Scheduler action was scheduled from.
*/
defined( 'ABSPATH' ) || exit;
add_action( 'action_scheduler_stored_action', 'dpc_as_trace_log_source', 20 );
/**
* Add a "Scheduled from ..." entry to the action's log.
*/
function dpc_as_trace_log_source( $action_id ): void {
$action_id = (int) $action_id;
if ( $action_id <= 0 || ! class_exists( 'ActionScheduler' ) || ! class_exists( 'ActionScheduler_Logger' ) ) {
return;
}
// Optional: only trace these hooks. Empty array = trace everything.
$hooks = array_filter( (array) apply_filters( 'dpc_as_trace_hooks', array() ) );
if ( $hooks ) {
$action = ActionScheduler::store()->fetch_action( (string) $action_id );
if ( ! in_array( $action->get_hook(), $hooks, true ) ) {
return;
}
}
$message = sprintf(
'Scheduled from %1$s during %2$s',
dpc_as_trace_find_caller(),
dpc_as_trace_request_context()
);
ActionScheduler_Logger::instance()->log( $action_id, $message );
}
/**
* Walk the backtrace and return the first frame outside Action Scheduler and WordPress hook plumbing.
*/
function dpc_as_trace_find_caller(): string {
$skip = (array) apply_filters(
'dpc_as_trace_skip_paths',
array(
'/action-scheduler/',
'/woocommerce/includes/queue/',
'/wp-includes/class-wp-hook.php',
'/wp-includes/plugin.php',
)
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
$frames = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 40 );
$root = wp_normalize_path( ABSPATH );
foreach ( $frames as $i => $frame ) {
if ( empty( $frame['file'] ) || str_starts_with( $frame['function'] ?? '', 'dpc_as_trace_' ) ) {
continue;
}
$file = wp_normalize_path( $frame['file'] );
foreach ( $skip as $needle ) {
if ( '' !== $needle && str_contains( $file, $needle ) ) {
continue 2;
}
}
$where = str_starts_with( $file, $root ) ? substr( $file, strlen( $root ) ) : $file;
$where .= ':' . (int) ( $frame['line'] ?? 0 );
// The next frame is the function that contains this call.
if ( ! empty( $frames[ $i + 1 ]['function'] ) ) {
$outer = $frames[ $i + 1 ];
$class = isset( $outer['class'] ) ? $outer['class'] . ( $outer['type'] ?? '::' ) : '';
$where .= ' in ' . $class . $outer['function'] . '()';
}
return $where;
}
return 'Action Scheduler itself (recurring run, retry or migration)';
}
/**
* Describe the request that scheduled the action. Path only, never the query string.
*/
function dpc_as_trace_request_context(): string {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
$type = 'WP-CLI';
} elseif ( wp_doing_cron() ) {
$type = 'WP-Cron';
} elseif ( wp_doing_ajax() ) {
$type = 'admin-ajax';
} elseif ( function_exists( 'wp_is_serving_rest_request' ) && wp_is_serving_rest_request() ) {
$type = 'REST';
} elseif ( is_admin() ) {
$type = 'wp-admin';
} else {
$type = 'front end';
}
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
$path = '';
if ( isset( $_SERVER['REQUEST_URI'] ) ) {
$path = (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH );
}
return trim( sprintf( '%s %s %s (user #%d)', $type, $method, $path, get_current_user_id() ) );
}
How it works
- Action Scheduler fires
action_scheduler_stored_actionwith the new action ID each time it saves an action. Its own “action created” log entry runs at priority 10. The snippet runs at 20, so its entry appears just after that one. debug_backtrace()withDEBUG_BACKTRACE_IGNORE_ARGScollects the call stack without copying arguments, which keeps it cheap and keeps order data or tokens out of the log.- The loop skips the snippet’s own functions, Action Scheduler’s files, WooCommerce’s
WC()->queue()wrapper and the WordPress hook classes. The first frame left over is the code that asked for the action, for examplewp-content/plugins/some-plugin/src/Sync.php:88 in Some\Sync->queue(). - The request context records only the URL path, never the query string, so nonces and keys in URLs stay out of the database. The log is shown in an admin screen that Action Scheduler already escapes.
Customise it
Trace only the hooks you are investigating. This leaves the rest of the queue untouched and skips the backtrace for them:
add_filter( 'dpc_as_trace_hooks', function (): array {
return array( 'woocommerce_run_product_attribute_lookup_update_callback', 'my_plugin_sync' );
} );
If a wrapper library also hides the real caller, add its folder to dpc_as_trace_skip_paths, for example '/vendor/my-queue-lib/'. To see every callback attached to the hook once you know who schedules it, use Find all functions hooked to a WordPress action / filter. For plain WP-Cron events, see WP CLI List Cron Jobs in WordPress With Overdue Events.
Gotchas
- Only actions created after the snippet is active get a trace. Older pending actions keep their original log.
- A recurring action reschedules itself after each run, so those later instances say “Action Scheduler itself”. To see the original caller, look at the first instance in the list, sorted by date.
- WooCommerce schedules many actions on a busy store. Taking a backtrace is cheap but it isn’t free, and the logs table grows. Narrow the trace with
dpc_as_trace_hooks, or remove the snippet once you have found the source. - When a store runs on Scripts Organizer or a code-snippets plugin, the trace can point into
wp-content/uploads/scorg/or similar. That is correct: the snippet stored there is the caller.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes