TenantAtlas/app/Services/Alerts/AlertDispatchService.php
2026-02-18 15:25:14 +01:00

193 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertDelivery;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\Workspace;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class AlertDispatchService
{
public function __construct(
private readonly AlertFingerprintService $fingerprintService,
private readonly AlertQuietHoursService $quietHoursService,
) {}
/**
* @param array<string, mixed> $event
*/
public function dispatchEvent(Workspace $workspace, array $event): int
{
$workspaceId = (int) $workspace->getKey();
$tenantId = (int) ($event['tenant_id'] ?? 0);
$eventType = trim((string) ($event['event_type'] ?? ''));
if ($workspaceId <= 0 || $tenantId <= 0 || $eventType === '') {
return 0;
}
$tenant = Tenant::query()
->whereKey($tenantId)
->where('workspace_id', $workspaceId)
->first();
if (! $tenant instanceof Tenant) {
return 0;
}
$now = CarbonImmutable::now('UTC');
$eventSeverity = $this->normalizeSeverity((string) ($event['severity'] ?? ''));
$rules = AlertRule::query()
->with(['destinations' => fn ($query) => $query->where('is_enabled', true)])
->where('workspace_id', $workspaceId)
->where('is_enabled', true)
->where('event_type', $eventType)
->orderBy('id')
->get();
$createdDeliveries = 0;
foreach ($rules as $rule) {
if (! $rule->appliesToTenant($tenantId)) {
continue;
}
if (! $this->meetsMinimumSeverity($eventSeverity, (string) $rule->minimum_severity)) {
continue;
}
foreach ($rule->destinations as $destination) {
$fingerprintHash = $this->fingerprintService->hash($rule, $destination, $tenantId, $event);
$isSuppressed = $this->shouldSuppress(
workspaceId: $workspaceId,
ruleId: (int) $rule->getKey(),
destinationId: (int) $destination->getKey(),
fingerprintHash: $fingerprintHash,
cooldownSeconds: (int) ($rule->cooldown_seconds ?? 0),
now: $now,
);
$sendAfter = null;
$status = AlertDelivery::STATUS_QUEUED;
if ($isSuppressed) {
$status = AlertDelivery::STATUS_SUPPRESSED;
} else {
$deferUntil = $this->quietHoursService->deferUntil($rule, $workspace, $now);
if ($deferUntil instanceof CarbonImmutable) {
$status = AlertDelivery::STATUS_DEFERRED;
$sendAfter = $deferUntil;
}
}
AlertDelivery::query()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenantId,
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'event_type' => $eventType,
'severity' => $eventSeverity,
'status' => $status,
'fingerprint_hash' => $fingerprintHash,
'send_after' => $sendAfter,
'attempt_count' => 0,
'payload' => $this->buildPayload($event),
]);
$createdDeliveries++;
}
}
return $createdDeliveries;
}
private function normalizeSeverity(string $severity): string
{
$severity = strtolower(trim($severity));
return in_array($severity, ['low', 'medium', 'high', 'critical'], true)
? $severity
: 'high';
}
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
{
$rank = [
'low' => 1,
'medium' => 2,
'high' => 3,
'critical' => 4,
];
$eventRank = $rank[$eventSeverity] ?? 0;
$minimumRank = $rank[strtolower(trim($minimumSeverity))] ?? 0;
return $eventRank >= $minimumRank;
}
private function shouldSuppress(
int $workspaceId,
int $ruleId,
int $destinationId,
string $fingerprintHash,
int $cooldownSeconds,
CarbonImmutable $now,
): bool {
if ($cooldownSeconds <= 0) {
return false;
}
$cutoff = $now->subSeconds($cooldownSeconds);
return AlertDelivery::query()
->where('workspace_id', $workspaceId)
->where('alert_rule_id', $ruleId)
->where('alert_destination_id', $destinationId)
->where('fingerprint_hash', $fingerprintHash)
->whereNotIn('status', [
AlertDelivery::STATUS_SUPPRESSED,
AlertDelivery::STATUS_CANCELED,
])
->where('created_at', '>=', $cutoff)
->exists();
}
/**
* @param array<string, mixed> $event
* @return array<string, mixed>
*/
private function buildPayload(array $event): array
{
$title = trim((string) ($event['title'] ?? 'Alert'));
$body = trim((string) ($event['body'] ?? 'A matching alert event was detected.'));
if ($title === '') {
$title = 'Alert';
}
if ($body === '') {
$body = 'A matching alert event was detected.';
}
$metadata = Arr::get($event, 'metadata', []);
if (! is_array($metadata)) {
$metadata = [];
}
return [
'title' => $title,
'body' => $body,
'metadata' => $metadata,
];
}
}