Skip to content

Instantly share code, notes, and snippets.

@adamzero1
Created July 29, 2026 09:59
Show Gist options
  • Select an option

  • Save adamzero1/1cda9d5d6b2a810fdbf69fa660277b14 to your computer and use it in GitHub Desktop.

Select an option

Save adamzero1/1cda9d5d6b2a810fdbf69fa660277b14 to your computer and use it in GitHub Desktop.
Magento 2 Cron Report
<?php
declare(strict_types=1);
// Prevent varnish or browsers from caching this dynamic report.
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
header('Surrogate-Control: no-store');
$envPath = __DIR__ . '/app/etc/env.php';
if (!is_file($envPath)) {
http_response_code(500);
echo '<h1>Configuration Error</h1><p>Could not find app/etc/env.php.</p>';
exit;
}
$env = require $envPath;
$dbConfig = $env['db']['connection']['default'] ?? null;
if (!is_array($dbConfig)) {
http_response_code(500);
echo '<h1>Configuration Error</h1><p>Database connection config is missing in env.php.</p>';
exit;
}
$host = (string)($dbConfig['host'] ?? '');
$dbName = (string)($dbConfig['dbname'] ?? '');
$username = (string)($dbConfig['username'] ?? '');
$password = (string)($dbConfig['password'] ?? '');
$now = new DateTimeImmutable('now');
$defaultEnd = $now->format('Y-m-d H:i');
$defaultStart = $now->modify('-1 hour')->format('Y-m-d H:i');
$startInput = isset($_GET['start']) ? trim((string)$_GET['start']) : $defaultStart;
$endInput = isset($_GET['end']) ? trim((string)$_GET['end']) : $defaultEnd;
$errors = [];
$start = DateTimeImmutable::createFromFormat('Y-m-d H:i', $startInput) ?: false;
$end = DateTimeImmutable::createFromFormat('Y-m-d H:i', $endInput) ?: false;
if (!$start) {
$errors[] = 'Start time must be in format YYYY-MM-DD HH:MM.';
}
if (!$end) {
$errors[] = 'End time must be in format YYYY-MM-DD HH:MM.';
}
if ($start && $end) {
if ($end < $start) {
$errors[] = 'End time must be after or equal to start time.';
}
$minutesDiff = (int)(($end->getTimestamp() - $start->getTimestamp()) / 60);
if ($minutesDiff > 24 * 60) {
$errors[] = 'Time range cannot exceed 24 hours.';
}
}
$rowsByMinute = [];
$statuses = [];
$minuteBuckets = [];
$jobRuntimeRows = [];
if (empty($errors) && $start && $end) {
try {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $dbName);
$pdo = new PDO(
$dsn,
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$sql = <<<'SQL'
SELECT
DATE_FORMAT(scheduled_at, '%Y-%m-%d %H:%i') AS minute_bucket,
status,
COUNT(*) AS job_count
FROM cron_schedule
WHERE scheduled_at >= :start_at
AND scheduled_at <= :end_at
GROUP BY minute_bucket, status
ORDER BY minute_bucket ASC, status ASC
SQL;
$statement = $pdo->prepare($sql);
$statement->execute([
':start_at' => $start->format('Y-m-d H:i:00'),
':end_at' => $end->format('Y-m-d H:i:59'),
]);
while ($row = $statement->fetch()) {
$minute = (string)$row['minute_bucket'];
$status = (string)$row['status'];
$count = (int)$row['job_count'];
$rowsByMinute[$minute][$status] = $count;
$statuses[$status] = true;
}
$cursor = $start;
while ($cursor <= $end) {
$minuteBuckets[] = $cursor->format('Y-m-d H:i');
$cursor = $cursor->modify('+1 minute');
}
} catch (Throwable $e) {
$errors[] = 'Database error: ' . $e->getMessage();
}
}
if (empty($errors) && $start && $end) {
try {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $dbName);
$pdo = new PDO(
$dsn,
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$runtimeSql = <<<'SQL'
SELECT
job_code,
COUNT(*) AS runs_count,
SUM(
GREATEST(
1,
TIMESTAMPDIFF(
SECOND,
executed_at,
COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR))
)
)
) AS total_runtime_seconds,
AVG(
GREATEST(
1,
TIMESTAMPDIFF(
SECOND,
executed_at,
COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR))
)
)
) AS avg_runtime_seconds,
MIN(
GREATEST(
1,
TIMESTAMPDIFF(
SECOND,
executed_at,
COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR))
)
)
) AS min_runtime_seconds,
MAX(
GREATEST(
1,
TIMESTAMPDIFF(
SECOND,
executed_at,
COALESCE(finished_at, DATE_ADD(executed_at, INTERVAL 1 HOUR))
)
)
) AS max_runtime_seconds
FROM cron_schedule
WHERE executed_at IS NOT NULL
AND executed_at >= :start_at
AND executed_at <= :end_at
GROUP BY job_code
ORDER BY total_runtime_seconds DESC, job_code ASC
SQL;
$runtimeStatement = $pdo->prepare($runtimeSql);
$runtimeStatement->execute([
':start_at' => $start->format('Y-m-d H:i:00'),
':end_at' => $end->format('Y-m-d H:i:59'),
]);
$jobRuntimeRows = $runtimeStatement->fetchAll() ?: [];
} catch (Throwable $e) {
$errors[] = 'Runtime query error: ' . $e->getMessage();
}
}
$statusColumns = array_keys($statuses);
sort($statusColumns);
$chartLabels = [];
$chartDatasets = [];
if (empty($errors) && !empty($statusColumns)) {
$chartLabels = $minuteBuckets;
$palette = [
'#1f77b4',
'#2ca02c',
'#ff7f0e',
'#d62728',
'#17becf',
'#9467bd',
'#8c564b',
'#bcbd22',
'#e377c2',
'#7f7f7f',
];
foreach ($statusColumns as $index => $status) {
$series = [];
foreach ($minuteBuckets as $minute) {
$series[] = (int)($rowsByMinute[$minute][$status] ?? 0);
}
$color = $palette[$index % count($palette)];
$chartDatasets[] = [
'label' => $status,
'data' => $series,
'borderColor' => $color,
'backgroundColor' => hexToRgba($color, 0.35),
'fill' => true,
'pointRadius' => 0,
'borderWidth' => 1,
'tension' => 0.2,
'stack' => 'cron_status',
];
}
}
$runtimePieLabels = [];
$runtimePieValues = [];
if (!empty($jobRuntimeRows)) {
foreach ($jobRuntimeRows as $jobRuntimeRow) {
$runtimePieLabels[] = (string)$jobRuntimeRow['job_code'];
$runtimePieValues[] = (int)$jobRuntimeRow['total_runtime_seconds'];
}
}
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function hexToRgba(string $hex, float $alpha): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) !== 6) {
return 'rgba(0,0,0,' . $alpha . ')';
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return sprintf('rgba(%d,%d,%d,%.2F)', $r, $g, $b, $alpha);
}
function secondsToReadable(int $seconds): string
{
$hours = intdiv($seconds, 3600);
$minutes = intdiv($seconds % 3600, 60);
$remainingSeconds = $seconds % 60;
if ($hours > 0) {
return sprintf('%dh %dm %ds', $hours, $minutes, $remainingSeconds);
}
if ($minutes > 0) {
return sprintf('%dm %ds', $minutes, $remainingSeconds);
}
return sprintf('%ds', $remainingSeconds);
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cron Schedule Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 20px;
color: #111;
background: #f6f7f9;
}
h1 {
margin-bottom: 16px;
}
form {
background: #fff;
border: 1px solid #ddd;
padding: 12px;
border-radius: 8px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: end;
margin-bottom: 16px;
}
label {
display: flex;
flex-direction: column;
font-size: 14px;
gap: 4px;
}
input[type="text"] {
padding: 8px;
border: 1px solid #bbb;
border-radius: 6px;
min-width: 220px;
}
button {
padding: 9px 14px;
border: 1px solid #333;
border-radius: 6px;
background: #222;
color: #fff;
cursor: pointer;
}
.notice {
margin-bottom: 16px;
padding: 10px;
border-radius: 6px;
background: #fff3cd;
border: 1px solid #ffe08a;
}
table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: right;
}
th:first-child,
td:first-child {
text-align: left;
white-space: nowrap;
}
thead th {
background: #f1f3f5;
}
.muted {
color: #555;
font-size: 13px;
margin-bottom: 8px;
}
.chart-wrap {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
margin-bottom: 16px;
}
.chart-area {
height: 360px;
}
.runtime-grid {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(500px, 2fr);
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 1024px) {
.runtime-grid {
grid-template-columns: 1fr;
}
}
.pie-area {
height: 360px;
}
</style>
</head>
<body>
<h1>Cron Schedule Report</h1>
<p class="muted">Grouped by <code>scheduled_at</code> minute and status.</p>
<form method="get" action="">
<label>
Start (YYYY-MM-DD HH:MM)
<input type="text" name="start" value="<?= h($startInput) ?>" required>
</label>
<label>
End (YYYY-MM-DD HH:MM)
<input type="text" name="end" value="<?= h($endInput) ?>" required>
</label>
<button type="submit">Run report</button>
</form>
<?php if (!empty($errors)): ?>
<div class="notice">
<?php foreach ($errors as $error): ?>
<div><?= h($error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (empty($errors) && $start && $end): ?>
<?php if (empty($statusColumns)): ?>
<div class="notice">No cron records found for the selected range.</div>
<?php else: ?>
<div class="chart-wrap">
<div class="muted">Stacked line graph of job status counts per minute.</div>
<div class="chart-area">
<canvas id="cronStatusChart" aria-label="Stacked cron status graph"></canvas>
</div>
</div>
<table>
<thead>
<tr>
<th>Minute</th>
<?php foreach ($statusColumns as $status): ?>
<th><?= h($status) ?></th>
<?php endforeach; ?>
<th>Total</th>
</tr>
</thead>
<tbody>
<?php foreach ($minuteBuckets as $minute): ?>
<?php
$row = $rowsByMinute[$minute] ?? [];
$total = 0;
?>
<tr>
<td><?= h($minute) ?></td>
<?php foreach ($statusColumns as $status): ?>
<?php
$value = (int)($row[$status] ?? 0);
$total += $value;
?>
<td><?= $value ?></td>
<?php endforeach; ?>
<td><strong><?= $total ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (empty($jobRuntimeRows)): ?>
<div class="notice">No job runtime records found for the selected range.</div>
<?php else: ?>
<div class="runtime-grid">
<div class="chart-wrap">
<div class="muted">Pie chart: share of total runtime by job.</div>
<div class="pie-area">
<canvas id="runtimePieChart" aria-label="Job runtime pie chart"></canvas>
</div>
</div>
<div>
<table>
<thead>
<tr>
<th>Job</th>
<th>Runs</th>
<th>Total Runtime</th>
<th>Average Runtime</th>
<th>Min Runtime</th>
<th>Max Runtime</th>
</tr>
</thead>
<tbody>
<?php foreach ($jobRuntimeRows as $runtimeRow): ?>
<?php
$jobCode = (string)$runtimeRow['job_code'];
$runs = (int)$runtimeRow['runs_count'];
$totalSeconds = (int)$runtimeRow['total_runtime_seconds'];
$avgSeconds = (int)round((float)$runtimeRow['avg_runtime_seconds']);
$minSeconds = (int)$runtimeRow['min_runtime_seconds'];
$maxSeconds = (int)$runtimeRow['max_runtime_seconds'];
?>
<tr>
<td><?= h($jobCode) ?></td>
<td><?= $runs ?></td>
<td><?= h(secondsToReadable($totalSeconds)) ?></td>
<td><?= h(secondsToReadable($avgSeconds)) ?></td>
<td><?= h(secondsToReadable($minSeconds)) ?></td>
<td><?= h(secondsToReadable($maxSeconds)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($chartDatasets) || !empty($runtimePieValues)): ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
const labels = <?= json_encode($chartLabels, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const datasets = <?= json_encode($chartDatasets, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const runtimePieLabels = <?= json_encode($runtimePieLabels, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const runtimePieValues = <?= json_encode($runtimePieValues, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const chartElement = document.getElementById('cronStatusChart');
const runtimePieElement = document.getElementById('runtimePieChart');
const piePalette = [
'#1f77b4', '#2ca02c', '#ff7f0e', '#d62728', '#17becf',
'#9467bd', '#8c564b', '#bcbd22', '#e377c2', '#7f7f7f'
];
if (chartElement && window.Chart) {
new Chart(chartElement, {
type: 'line',
data: {
labels,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'bottom'
}
},
scales: {
x: {
ticks: {
maxTicksLimit: 20
}
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: 'Job Count'
}
}
}
}
});
}
if (runtimePieElement && window.Chart && runtimePieValues.length > 0) {
const pieColors = runtimePieValues.map((_, index) => piePalette[index % piePalette.length]);
new Chart(runtimePieElement, {
type: 'pie',
data: {
labels: runtimePieLabels,
datasets: [{
data: runtimePieValues,
backgroundColor: pieColors,
borderColor: '#fff',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right'
},
tooltip: {
callbacks: {
label: (ctx) => {
const value = Number(ctx.raw || 0);
return `${ctx.label}: ${value}s`;
}
}
}
}
}
});
}
</script>
<?php endif; ?>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment