Skip to content

Commit 307524a

Browse files
Add MonitorHistorySeeder for local demo data
Seeds 4 demo monitors with distinct health profiles (healthy, flaky, expiring-domain, recovered-outage) and fabricates multi-day uptime/domain/ certificate check logs, then aggregates them into daily metrics so the monitor detail page heatmaps, tooltips, and totals have realistic data to render locally. - Run: php artisan db:seed --class=MonitorHistorySeeder - Window length via config('monitor-history.seed_days') / MONITOR_HISTORY_SEED_DAYS (default 90) - Re-runnable (updateOrCreate by URL, clears prior seeded history) - Guarded against running in production Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 249a119 commit 307524a

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

config/monitor-history.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,10 @@
3131
* How many days of raw logs to keep before pruning.
3232
*/
3333
'raw_log_retention_days' => 180,
34+
35+
/*
36+
* How many days of synthetic history MonitorHistorySeeder generates when
37+
* seeding demo data locally (php artisan db:seed --class=MonitorHistorySeeder).
38+
*/
39+
'seed_days' => (int) env('MONITOR_HISTORY_SEED_DAYS', 90),
3440
];
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
namespace Database\Seeders;
4+
5+
use App\Models\Monitor;
6+
use App\Services\DomainService;
7+
use App\Services\MonitorCheckLogService;
8+
use App\Services\MonitorDailyCheckMetricsAggregator;
9+
use Carbon\Carbon;
10+
use Illuminate\Database\Seeder;
11+
12+
/**
13+
* Seeds demo monitors with synthetic, multi-day check history so the monitor
14+
* detail page (heatmaps, tooltips, totals) has realistic data to render locally.
15+
*
16+
* Run with: php artisan db:seed --class=MonitorHistorySeeder
17+
* Remember to set MONITOR_HISTORY_ENABLED=true to see the history UI.
18+
*
19+
* The monitor URLs are placeholders — the history is fabricated, so live uptime
20+
* checks against them will not match the seeded data.
21+
*/
22+
class MonitorHistorySeeder extends Seeder
23+
{
24+
/**
25+
* Number of uptime checks fabricated per day (spread across the day).
26+
*/
27+
private const UPTIME_CHECKS_PER_DAY = 8;
28+
29+
/**
30+
* Demo monitors and the health profile used to fabricate their history.
31+
*
32+
* @var array<int, array<string, mixed>>
33+
*/
34+
private array $specs = [
35+
[
36+
'name' => 'Healthy API',
37+
'url' => 'https://healthy.monitor-demo.test',
38+
'uptime' => true,
39+
'domain' => true,
40+
'certificate' => true,
41+
'profile' => 'healthy',
42+
'domain_expires_in_days' => 220,
43+
],
44+
[
45+
'name' => 'Flaky Service',
46+
'url' => 'https://flaky.monitor-demo.test',
47+
'uptime' => true,
48+
'domain' => false,
49+
'certificate' => false,
50+
'profile' => 'flaky',
51+
],
52+
[
53+
'name' => 'Expiring Domain',
54+
'url' => 'https://expiring.monitor-demo.test',
55+
'uptime' => true,
56+
'domain' => true,
57+
'certificate' => false,
58+
'profile' => 'domain_expiring',
59+
'domain_expires_in_days' => 12,
60+
],
61+
[
62+
'name' => 'Recovered Outage',
63+
'url' => 'https://outage.monitor-demo.test',
64+
'uptime' => true,
65+
'domain' => false,
66+
'certificate' => false,
67+
'profile' => 'outage',
68+
],
69+
];
70+
71+
public function run(): void
72+
{
73+
if (app()->environment('production')) {
74+
$this->command?->warn('MonitorHistorySeeder skipped: not allowed in production.');
75+
76+
return;
77+
}
78+
79+
$days = max(1, (int) config('monitor-history.seed_days', 90));
80+
$timezone = config('monitor-history.timezone') ?: config('app.timezone', 'UTC');
81+
$logService = app(MonitorCheckLogService::class);
82+
$domainService = app(DomainService::class);
83+
84+
$now = Carbon::now('UTC');
85+
$rangeStart = $now->copy()->subDays($days - 1)->startOfDay();
86+
87+
foreach ($this->specs as $spec) {
88+
$monitor = $this->createMonitor($spec, $now);
89+
90+
// Make the seed re-runnable: clear previously seeded history for this monitor.
91+
$monitor->checkLogs()->delete();
92+
$monitor->dailyCheckMetrics()->delete();
93+
94+
for ($dayOffset = 0; $dayOffset < $days; $dayOffset++) {
95+
$day = $rangeStart->copy()->addDays($dayOffset);
96+
97+
if ($monitor->uptime_check_enabled) {
98+
$this->seedUptimeDay($logService, $monitor, $day, $dayOffset, $days, $spec['profile']);
99+
}
100+
101+
if (! empty($spec['domain'])) {
102+
$this->seedDomainDay($logService, $domainService, $monitor, $day, $spec);
103+
}
104+
105+
if (! empty($spec['certificate'])) {
106+
$this->seedCertificateDay($logService, $monitor, $day);
107+
}
108+
}
109+
}
110+
111+
$rows = app(MonitorDailyCheckMetricsAggregator::class)->aggregate(
112+
$rangeStart,
113+
$now,
114+
$timezone
115+
);
116+
117+
$this->command?->info(sprintf(
118+
'Seeded %d demo monitors with %d days of history (%d daily metric buckets, timezone %s).',
119+
count($this->specs),
120+
$days,
121+
$rows,
122+
$timezone
123+
));
124+
$this->command?->info('Set MONITOR_HISTORY_ENABLED=true to view the history UI.');
125+
}
126+
127+
private function createMonitor(array $spec, Carbon $now): Monitor
128+
{
129+
$domainExpiresAt = isset($spec['domain_expires_in_days'])
130+
? $now->copy()->addDays((int) $spec['domain_expires_in_days'])
131+
: null;
132+
133+
return Monitor::updateOrCreate(
134+
['url' => $spec['url']],
135+
[
136+
'name' => $spec['name'],
137+
'uptime_check_enabled' => (bool) $spec['uptime'],
138+
'domain_check_enabled' => (bool) $spec['domain'],
139+
'certificate_check_enabled' => (bool) $spec['certificate'],
140+
'domain_expires_at' => $domainExpiresAt,
141+
]
142+
);
143+
}
144+
145+
private function seedUptimeDay(
146+
MonitorCheckLogService $logService,
147+
Monitor $monitor,
148+
Carbon $day,
149+
int $dayOffset,
150+
int $totalDays,
151+
string $profile
152+
): void {
153+
// failureCount = how many of the day's checks fail (deterministic per profile/day).
154+
$failureCount = $this->uptimeFailuresForDay($profile, $dayOffset, $totalDays);
155+
$intervalMinutes = (int) floor((24 * 60) / self::UPTIME_CHECKS_PER_DAY);
156+
157+
for ($checkIndex = 0; $checkIndex < self::UPTIME_CHECKS_PER_DAY; $checkIndex++) {
158+
$checkedAt = $day->copy()->addMinutes($checkIndex * $intervalMinutes);
159+
if ($checkedAt->greaterThan(Carbon::now('UTC'))) {
160+
break;
161+
}
162+
163+
$isFailure = $checkIndex < $failureCount;
164+
$status = $isFailure
165+
? MonitorCheckLogService::STATUS_FAILED
166+
: MonitorCheckLogService::STATUS_SUCCESS;
167+
168+
$logService->logCheck(
169+
monitor: $monitor,
170+
checkType: MonitorCheckLogService::CHECK_TYPE_UPTIME,
171+
status: $status,
172+
checkedAt: $checkedAt,
173+
message: $isFailure ? 'Uptime check failed.' : 'Uptime check succeeded.',
174+
failureReason: $isFailure ? 'Connection timed out (seeded).' : null,
175+
responseTimeMs: $isFailure ? null : 120 + (($checkIndex * 17) % 90),
176+
);
177+
}
178+
}
179+
180+
private function uptimeFailuresForDay(string $profile, int $dayOffset, int $totalDays): int
181+
{
182+
return match ($profile) {
183+
// Mostly healthy, with an occasional single blip.
184+
'healthy' => ($dayOffset % 13 === 0) ? 1 : 0,
185+
// Frequently degraded: ~1/3 of days lose several checks.
186+
'flaky' => ($dayOffset % 3 === 0) ? 4 : (($dayOffset % 5 === 0) ? 1 : 0),
187+
// A contiguous full outage in the middle of the window, healthy otherwise.
188+
'outage' => $this->isInOutageWindow($dayOffset, $totalDays) ? self::UPTIME_CHECKS_PER_DAY : 0,
189+
default => 0,
190+
};
191+
}
192+
193+
private function isInOutageWindow(int $dayOffset, int $totalDays): bool
194+
{
195+
$start = intdiv($totalDays, 2);
196+
197+
return $dayOffset >= $start && $dayOffset < $start + 3;
198+
}
199+
200+
private function seedDomainDay(
201+
MonitorCheckLogService $logService,
202+
DomainService $domainService,
203+
Monitor $monitor,
204+
Carbon $day,
205+
array $spec
206+
): void {
207+
$checkedAt = $day->copy()->setTime(2, 0);
208+
if ($checkedAt->greaterThan(Carbon::now('UTC'))) {
209+
return;
210+
}
211+
212+
$expiresInDays = (int) ($spec['domain_expires_in_days'] ?? 200);
213+
// Expiry is a fixed future date; on an earlier day there are more days left,
214+
// so the value shrinks toward $expiresInDays as the history approaches today.
215+
$daysUntilExpiration = $expiresInDays + (int) $checkedAt->diffInDays(Carbon::now('UTC'));
216+
217+
$outcome = $domainService->resolveDomainExpirationOutcome($daysUntilExpiration);
218+
219+
$logService->logCheck(
220+
monitor: $monitor,
221+
checkType: MonitorCheckLogService::CHECK_TYPE_DOMAIN,
222+
status: $outcome['status'],
223+
checkedAt: $checkedAt,
224+
message: $outcome['message'],
225+
metadata: [
226+
'days_until_expiration' => $daysUntilExpiration,
227+
'source' => 'seed',
228+
],
229+
);
230+
}
231+
232+
private function seedCertificateDay(
233+
MonitorCheckLogService $logService,
234+
Monitor $monitor,
235+
Carbon $day
236+
): void {
237+
$checkedAt = $day->copy()->setTime(3, 0);
238+
if ($checkedAt->greaterThan(Carbon::now('UTC'))) {
239+
return;
240+
}
241+
242+
$logService->logCheck(
243+
monitor: $monitor,
244+
checkType: MonitorCheckLogService::CHECK_TYPE_CERTIFICATE,
245+
status: MonitorCheckLogService::STATUS_SUCCESS,
246+
checkedAt: $checkedAt,
247+
message: 'Certificate is valid.',
248+
metadata: ['source' => 'seed'],
249+
);
250+
}
251+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Tests\Feature\MonitorHistory;
4+
5+
use App\Models\Monitor;
6+
use App\Models\MonitorCheckLog;
7+
use App\Models\MonitorDailyCheckMetric;
8+
use Database\Seeders\MonitorHistorySeeder;
9+
use Illuminate\Foundation\Testing\RefreshDatabase;
10+
use Tests\TestCase;
11+
12+
class MonitorHistorySeederTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
public function test_it_seeds_monitors_with_logs_and_aggregated_daily_metrics(): void
17+
{
18+
config(['monitor-history.seed_days' => 10]);
19+
20+
$this->seed(MonitorHistorySeeder::class);
21+
22+
$this->assertGreaterThan(0, Monitor::count());
23+
$this->assertGreaterThan(0, MonitorCheckLog::count());
24+
$this->assertGreaterThan(0, MonitorDailyCheckMetric::count());
25+
26+
$uptimeMetric = MonitorDailyCheckMetric::where('check_type', 'uptime')->first();
27+
$this->assertNotNull($uptimeMetric);
28+
$this->assertGreaterThan(0, $uptimeMetric->total_checks);
29+
30+
// There should be at least one unhealthy day so the heatmap shows colour variety.
31+
$this->assertTrue(
32+
MonitorDailyCheckMetric::where('worst_status', 'failed')->exists(),
33+
'Expected at least one failed day in the seeded history.'
34+
);
35+
}
36+
37+
public function test_re_running_the_seeder_does_not_duplicate_monitors(): void
38+
{
39+
config(['monitor-history.seed_days' => 5]);
40+
41+
$this->seed(MonitorHistorySeeder::class);
42+
$countAfterFirst = Monitor::count();
43+
44+
$this->seed(MonitorHistorySeeder::class);
45+
46+
$this->assertSame($countAfterFirst, Monitor::count());
47+
}
48+
}

0 commit comments

Comments
 (0)