Query Monitor is the gold-standard WordPress profiling plugin, but its in-browser output is only half the story. Combining Query Monitor’s hooks with a custom file logger lets you capture slow-query and hook-timing data in production — where you cannot have the plugin active for all users — and pipe the data to a log aggregator or alerting system for continuous performance monitoring.
The code below logs slow SQL queries using Query Monitor's data hook, captures slow hooks with the timer API, writes structured JSON logs to a rotating file, and shows how to parse the output for trend analysis.
<?php // functions.php or must-use plugin
// 1. Log slow queries (>100ms) to a JSON log file
add_action( 'shutdown', function () {
if ( ! class_exists( 'QM_Collectors' ) ) {
return;
}
$collector = QM_Collectors::get( 'db_queries' );
if ( ! $collector ) {
return;
}
$data = $collector->get_data();
if ( empty( $data['queries'] ) ) {
return;
}
$log_file = WP_CONTENT_DIR . '/logs/slow-queries.log';
$log_dir = dirname( $log_file );
if ( ! is_dir( $log_dir ) ) {
wp_mkdir_p( $log_dir );
}
foreach ( $data['queries'] as $query ) {
if ( ( $query['ltime'] ?? 0 ) < 0.1 ) {
continue; // skip queries faster than 100ms
}
$entry = json_encode( [
'ts' => date( 'c' ),
'url' => esc_url_raw( ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] ),
'ms' => round( $query['ltime'] * 1000, 2 ),
'sql' => substr( $query['sql'] ?? '', 0, 500 ),
'caller' => $query['caller'] ?? '',
] );
file_put_contents( $log_file, $entry . PHP_EOL, FILE_APPEND | LOCK_EX ); // phpcs:ignore
}
} );
// 2. Log slow hook execution (hooks taking >50ms)
if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) {
$hook_timings = [];
add_action( 'all', function ( $hook_name ) use ( &$hook_timings ) {
$hook_timings[ $hook_name ] = microtime( true );
} );
add_action( 'shutdown', function () use ( &$hook_timings ) {
$log_file = WP_CONTENT_DIR . '/logs/slow-hooks.log';
foreach ( $hook_timings as $hook => $start ) {
$elapsed = ( microtime( true ) - $start ) * 1000;
if ( $elapsed > 50 ) {
file_put_contents( // phpcs:ignore
$log_file,
json_encode( [ 'hook' => $hook, 'ms' => round( $elapsed, 2 ), 'ts' => date('c') ] ) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
}
} );
}
# Analyse slow-queries.log: top 10 slowest SQL patterns
cat wp-content/logs/slow-queries.log \
| jq -r '.sql' \
| sort | uniq -c | sort -rn | head -10
# Average query time per URL
cat wp-content/logs/slow-queries.log \
| jq -rs 'group_by(.url) | map({url: .[0].url, avg_ms: (map(.ms) | add/length)}) | sort_by(-.avg_ms) | .[:10]'
NOTE: Never enable the all hook timing logger in production without a query count guard — it fires on every single WordPress hook (hundreds per request) and will add measurable overhead and gigabytes of log data per day; enable it only on staging or behind a ?qm_debug=1 cookie check with a nonce.