Health monitoring
Integration health is tracked automatically based on request outcomes. It shares its definition of "failure" with the circuit breaker: both are driven by the same FailureClassifier, so they never disagree about whether an integration is broken. The two are otherwise distinct — health is a durable, per-integration record (Healthy → Degraded → Failing → Disabled) that drives sync backoff and auto-disabling, while the breaker is transient, cache-backed, and gates individual requests.
How it works
Each successful request resets consecutive_failures to 0 and sets health_status to healthy. Each upstream failure increments consecutive_failures and updates last_error_at.
Only FailureClass::Upstream (5xx except 501, connection errors, timeouts) counts toward health. A 429 throttle, a 4xx client error, or an unrecognised SDK exception leaves consecutive_failures untouched — a misbehaving caller spraying 404s can't degrade or auto-disable an integration for everyone sharing it. See What counts as a failure for the full classification.
| Consecutive Failures | Status | Default Threshold |
|---|---|---|
| 0 | healthy | -- |
| 5+ | degraded | health.degraded_after |
| 20+ | failing | health.failing_after |
| 50+ | disabled | health.disabled_after |
Any subsequent success resets back to healthy.
Disabled integrations stop syncing entirely and require manual re-enabling. Set health.disabled_after to null to disable automatic disabling.
Events
Every health transition dispatches an IntegrationHealthChanged event with the previous and new status. When an integration is auto-disabled, an IntegrationDisabled event is also dispatched.
use Integrations\Events\IntegrationHealthChanged;
class NotifyOnHealthDegradation
{
public function handle(IntegrationHealthChanged $event): void
{
if ($event->newStatus->value !== 'healthy') {
// Notify the team
}
}
}Failure summary
Integration::failureSummary(CarbonInterface $since) returns a report over a window — the same shape integrations:health and integrations:stats render, so a status page and the CLI agree on the numbers:
$summary = $integration->failureSummary(now()->subDay());
$summary->failureRate(); // failed share of all requests (0–100)
$summary->byFailureClass; // ['upstream' => 12, 'throttle' => 3, 'client' => 1, 'unknown' => 0]
$summary->byStatus; // ['5xx' => 12, '4xx' => 1, '429' => 3, 'other' => 0]
$summary->topErrors; // list<TopError> (message + count), highest first
$summary->lastErrorMessage; // most recent error string
$summary->operations['sync']; // OperationFailureBreakdown: total / successful / partial / failed / distinctItemsThe headline failureRate() counts every failed request; use byFailureClass to derive an upstream-only rate, since only FailureClass::Upstream degrades health. The breakdown draws from integration_requests (HTTP-level failures) and integration_logs (operation-level outcomes). failure_class is persisted on each request row, so the per-class breakdown is queryable without re-classifying.
Sync staleness
health_status reflects whether the provider's API is answering, which is not the same as whether syncing is happening. An integration can fetch successfully and then fail while processing what it fetched. Every pass calls recordSuccess(), so consecutive_failures sits at zero and health_status stays healthy while nothing syncs. Health alone never reports that condition, however long it lasts.
Staleness is the separate signal for that. An active integration is stale when its last clean sync is older than sync_interval_minutes multiplied by sync.stale_after_intervals. For an integration that has never synced, the age is measured from when it was created, so a newly connected one is not stale before its first run.
$integration->isSyncStale(); // bool
$integration->syncStaleness(); // ?int, seconds since the last clean syncintegrations:health and integrations:list both flag it, and integrations:sync emits SyncBecameStale once per episode, with SyncStalenessRecovered when a clean sync resolves it. The open/closed state is stored in a sync_stale_alerted_at column rather than a cache entry, so nothing expires or is flushed between the two events.
Staleness deliberately stays out of the HealthStatus enum. recordSuccess() sets health_status to Healthy on every successful request, so a staleness degradation written there would be overwritten within seconds. Folding the two together would also blur two failures that need different responses: the provider is failing, or your own code is failing on data the provider returned correctly.
Incident history
Health transitions and circuit trips are events, and circuit state is cache-only and lost on cache:clear. The package records a durable integration_incidents audit from its own IntegrationHealthChanged, CircuitOpened, CircuitClosed, IntegrationDisabled, SyncBecameStale, and SyncStalenessRecovered events.
There's one open incident per integration: health degradation, circuit trips, and sync staleness fold into the same row. The row tracks the worst severity reached (peak_severity) and records which signal opened it in source (health, circuit, or sync). It closes only when every signal is clear: health back to Healthy and the sync no longer stale. Health alone is not enough, because a stuck integration keeps making successful requests. Operator overrides (forced_open / forced_closed) are deliberate actions, not detected failures, so they neither open nor close an incident.
$integration->has_open_incident; // bool (accessor; reads the loaded incidents relation)
$integration->current_incident; // ?IntegrationIncident
$integration->incidents()->since(now()->subWeek())->get();integrations:prune sweeps closed incidents (pruning.incidents_days, default 365) and, as a safety net, auto-closes incidents left open past pruning.incidents_stale_after_days for an integration that's currently healthy. A sync-stale integration is exempt from that sweep: it counts as healthy by health_status, and staleness can outlast the threshold, so closing its incident on age would hide a condition that is still current. Turn the whole audit off with observability.incidents_enabled.
Health checks
Providers that implement HasHealthCheck can be probed without running a full sync:
use Integrations\Contracts\HasHealthCheck;
interface HasHealthCheck
{
public function healthCheck(Integration $integration): bool;
}class GitHubProvider implements IntegrationProvider, HasHealthCheck
{
public function healthCheck(Integration $integration): bool
{
try {
$integration
->at('/user')
->as(UserResponse::class)
->get(fn () => Http::withHeaders([
'Authorization' => 'Bearer '.$integration->credentialsArray()['token'],
])->get('https://api.github.com/user'));
return true;
} catch (\Throwable) {
return false;
}
}
}Run health checks from the CLI:
php artisan integrations:testintegrations:health and integrations:list also surface each integration's live circuit-breaker state (or active override) alongside its health status.
Querying by health
Integration::where('health_status', 'failing')->get();
Integration::where('health_status', 'degraded')->get();Effect on sync scheduling
Health status affects sync frequency. See Scheduled Syncs for the backoff multiplier table.
Configuration
// config/integrations.php
'health' => [
'degraded_after' => 5, // consecutive failures -> degraded
'failing_after' => 20, // consecutive failures -> failing
'disabled_after' => 50, // consecutive failures -> disabled (null = never)
'degraded_backoff' => 2, // sync interval multiplier when degraded
'failing_backoff' => 10, // sync interval multiplier when failing
],