Add integrations health monitoring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-02 18:35:12 +01:00
parent 9057a4cd15
commit fc3e6715db
21 changed files with 715 additions and 13 deletions

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services\Integrations;
use App\Jobs\ProcessRevenueCatWebhook;
use App\Models\IntegrationWebhookEvent;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class IntegrationHealthService
{
/**
* @return array<int, array<string, mixed>>
*/
public function providers(): array
{
return [
$this->buildProvider('paddle', 'Paddle', [
'is_configured' => filled(config('paddle.webhook_secret')),
'label' => 'Webhook secret',
]),
$this->buildProvider('revenuecat', 'RevenueCat', [
'is_configured' => filled(config('services.revenuecat.webhook')),
'label' => 'Webhook secret',
'queue' => config('services.revenuecat.queue', 'webhooks'),
'job_class' => ProcessRevenueCatWebhook::class,
]),
];
}
/**
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
private function buildProvider(string $provider, string $label, array $config): array
{
$query = IntegrationWebhookEvent::query()->where('provider', $provider);
$lastEvent = (clone $query)->orderByDesc('received_at')->first();
$lastProcessed = (clone $query)
->where('status', IntegrationWebhookEvent::STATUS_PROCESSED)
->orderByDesc('processed_at')
->first();
$lastFailed = (clone $query)
->where('status', IntegrationWebhookEvent::STATUS_FAILED)
->orderByDesc('failed_at')
->first();
$pendingCount = (clone $query)
->where('status', IntegrationWebhookEvent::STATUS_RECEIVED)
->count();
$recentFailures = (clone $query)
->where('status', IntegrationWebhookEvent::STATUS_FAILED)
->where('failed_at', '>=', now()->subDay())
->count();
$queueName = $config['queue'] ?? null;
$jobClass = $config['job_class'] ?? null;
$queueBacklog = $this->countJobs($queueName, $jobClass);
$failedJobs = $this->countFailedJobs($queueName, $jobClass);
$status = $this->resolveStatus(
(bool) ($config['is_configured'] ?? false),
$pendingCount,
$recentFailures,
$failedJobs,
$lastEvent
);
return [
'provider' => $provider,
'label' => $label,
'config_label' => (string) ($config['label'] ?? 'Config'),
'is_configured' => (bool) ($config['is_configured'] ?? false),
'status' => $status,
'status_label' => $this->statusLabel($status),
'pending_count' => $pendingCount,
'recent_failures' => $recentFailures,
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_event' => $this->formatEvent($lastEvent),
'last_processed' => $this->formatEvent($lastProcessed),
'last_failed' => $this->formatEvent($lastFailed),
'processing_lag' => $this->formatLag($lastEvent),
];
}
private function resolveStatus(
bool $isConfigured,
int $pendingCount,
int $recentFailures,
int $failedJobs,
?IntegrationWebhookEvent $lastEvent,
): string {
if (! $isConfigured) {
return 'unconfigured';
}
if ($pendingCount > 0) {
return 'pending';
}
if ($recentFailures > 0 || $failedJobs > 0) {
return 'degraded';
}
if (! $lastEvent) {
return 'unknown';
}
return 'healthy';
}
private function statusLabel(string $status): string
{
return match ($status) {
'healthy' => __('admin.integrations_health.status.healthy'),
'pending' => __('admin.integrations_health.status.pending'),
'degraded' => __('admin.integrations_health.status.degraded'),
'unconfigured' => __('admin.integrations_health.status.unconfigured'),
default => __('admin.integrations_health.status.unknown'),
};
}
private function formatEvent(?IntegrationWebhookEvent $event): ?array
{
if (! $event) {
return null;
}
return [
'status' => $event->status,
'event_type' => $event->event_type,
'received_at' => $event->received_at,
'processed_at' => $event->processed_at,
'failed_at' => $event->failed_at,
'error_message' => $event->error_message,
];
}
private function formatLag(?IntegrationWebhookEvent $event): ?array
{
if (! $event || ! $event->received_at) {
return null;
}
$end = $event->processed_at ?? now();
$seconds = $end->diffInSeconds($event->received_at);
return [
'seconds' => $seconds,
'label' => Carbon::now()->subSeconds($seconds)->diffForHumans(null, true),
];
}
private function countJobs(?string $queueName, ?string $jobClass): int
{
if (! $queueName || ! $jobClass) {
return 0;
}
return (int) DB::table('jobs')
->where('queue', $queueName)
->where('payload', 'like', '%'.$jobClass.'%')
->count();
}
private function countFailedJobs(?string $queueName, ?string $jobClass): int
{
if (! $queueName || ! $jobClass) {
return 0;
}
return (int) DB::table('failed_jobs')
->where('queue', $queueName)
->where('payload', 'like', '%'.$jobClass.'%')
->count();
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\Integrations;
use App\Models\IntegrationWebhookEvent;
use Illuminate\Support\Str;
class IntegrationWebhookRecorder
{
/**
* @param array<string, mixed> $context
*/
public function recordReceived(string $provider, ?string $eventId, ?string $eventType, array $context = []): IntegrationWebhookEvent
{
return IntegrationWebhookEvent::create([
'provider' => $provider,
'event_id' => $eventId,
'event_type' => $eventType,
'status' => IntegrationWebhookEvent::STATUS_RECEIVED,
'received_at' => now(),
'context' => $context,
]);
}
/**
* @param array<string, mixed> $context
*/
public function markProcessed(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent
{
$event->forceFill([
'status' => IntegrationWebhookEvent::STATUS_PROCESSED,
'processed_at' => now(),
'context' => $this->mergeContext($event, $context),
])->save();
return $event;
}
/**
* @param array<string, mixed> $context
*/
public function markIgnored(IntegrationWebhookEvent $event, array $context = []): IntegrationWebhookEvent
{
$event->forceFill([
'status' => IntegrationWebhookEvent::STATUS_IGNORED,
'processed_at' => now(),
'context' => $this->mergeContext($event, $context),
])->save();
return $event;
}
/**
* @param array<string, mixed> $context
*/
public function markFailed(IntegrationWebhookEvent $event, string $message, array $context = []): IntegrationWebhookEvent
{
$event->forceFill([
'status' => IntegrationWebhookEvent::STATUS_FAILED,
'failed_at' => now(),
'error_message' => Str::limit($message, 500, ''),
'context' => $this->mergeContext($event, $context),
])->save();
return $event;
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function mergeContext(IntegrationWebhookEvent $event, array $context): array
{
$existing = $event->context ?? [];
if (! is_array($existing)) {
$existing = [];
}
return array_merge($existing, $context);
}
}