SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%

feat(monitoring): statut retired — retrait des sources désactivées ou disparues

Le registre connector_health est persisté et jamais purgé : une source
désactivée côté app (ex. stores fabri-ka enabled=0) restait broken/stale à
perpétuité et les agents gardiens la missionnaient sans fin (2026-08-24 :
37 missions ka6 sur origin-north.ai). Désormais :
- nouvelle colonne last_seen (dernière apparition dans une fenêtre de sync,
  ALTER TABLE appliqué en prod + backfill sur checked_at) ;
- statut retired si la source est déclarée désactivée par l app
  (disabled_stores/disabled_sources du /api/stats — 58 sources fabri-ka
  retirées au premier run) ou absente de tout journal depuis > 14 j ;
- une source retired qui réapparaît dans le journal est réévaluée
  normalement (last_seen remis à jour, statut recalculé) ;
- retired exposé dans les résumés (monitoring, /health) et le détail
  (last_seen ajouté), jamais alerté, ignoré par les gardiens (trigger
  broken/stale inchangé).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent 38bd2f4

4 changed files +69 −10

modified src/api/routes/monitoring.py +1 −0
@@ -39,6 +39,7 @@ def _row_to_dict(row: ConnectorHealth) -> dict[str, Any]:
39 39 "median_found": row.median_found,
40 40 "consecutive_failures": row.consecutive_failures,
41 41 "message": row.message,
42 + "last_seen": row.last_seen.isoformat() if row.last_seen else None,
42 43 }
43 44
44 45
modified src/database/models.py +6 −1
@@ -216,7 +216,7 @@ class ConnectorHealth(Base):
216 216 )
217 217 status: Mapped[str] = mapped_column(
218 218 Text, nullable=False
219 − ) # ok / degraded / broken / stale
219 + ) # ok / degraded / broken / stale / retired
220 220 last_success: Mapped[datetime.datetime | None] = mapped_column(
221 221 DateTime(timezone=True), nullable=True
222 222 )
@@ -226,3 +226,8 @@ class ConnectorHealth(Base):
226 226 Integer, nullable=False, default=0
227 227 )
228 228 message: Mapped[str | None] = mapped_column(Text, nullable=True)
229 + # Dernière apparition de la source dans une fenêtre de sync (pilote le
230 + # retrait ``retired`` des sources disparues du journal).
231 + last_seen: Mapped[datetime.datetime | None] = mapped_column(
232 + DateTime(timezone=True), nullable=True
233 + )
modified src/monitoring/connector_health.py +61 −9
@@ -67,7 +67,19 @@ STATUS_OK = "ok"
67 67 STATUS_DEGRADED = "degraded"
68 68 STATUS_BROKEN = "broken"
69 69 STATUS_STALE = "stale"
70 −ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE)
70 +# Source retirée de la supervision active : désactivée explicitement par
71 +# l'app (``disabled_stores``/``disabled_sources`` du /api/stats) ou disparue
72 +# de toute fenêtre de sync depuis > RETIRED_AFTER_DAYS. Sans ce statut, la
73 +# ligne restait broken/stale à perpétuité (le registre est persisté, jamais
74 +# purgé) et les agents gardiens missionnaient sans fin des sources mortes
75 +# (2026-08-24 : 37 missions ka6 sur origin-north.ai, connecteur désactivé).
76 +# La ligne se réévalue normalement si la source réapparaît dans le journal.
77 +STATUS_RETIRED = "retired"
78 +ALL_STATUSES = (STATUS_OK, STATUS_DEGRADED, STATUS_BROKEN, STATUS_STALE, STATUS_RETIRED)
79 +
80 +# Retrait après N jours sans apparaître dans aucune fenêtre de sync (marge
81 +# large au-dessus des rotations observées, ~11-30 h selon les apps).
82 +RETIRED_AFTER_DAYS = 14
71 83
72 84 # broken après N échecs (ou 0 résultat) consécutifs.
73 85 BROKEN_THRESHOLD = 3
@@ -110,6 +122,7 @@ class SourceState:
110 122 median_found: float | None = None
111 123 consecutive_failures: int = 0
112 124 checked_at: datetime.datetime | None = None
125 + last_seen: datetime.datetime | None = None
113 126
114 127
115 128 @dataclass
@@ -397,6 +410,7 @@ def _state_from_row(row: ConnectorHealth | None) -> SourceState:
397 410 median_found=row.median_found,
398 411 consecutive_failures=row.consecutive_failures or 0,
399 412 checked_at=_ensure_utc(row.checked_at),
413 + last_seen=_ensure_utc(row.last_seen),
400 414 )
401 415
402 416
@@ -415,8 +429,14 @@ def _apply(
415 429 source: str,
416 430 assessment: Assessment,
417 431 now: float,
432 + seen: bool = False,
418 433 ) -> str:
419 − """Upsert de la ligne connector_health + alerte sur transition broken/stale."""
434 + """Upsert de la ligne connector_health + alerte sur transition broken/stale.
435 +
436 + ``seen`` indique que la source est apparue dans la fenêtre de sync de ce
437 + run : ``last_seen`` est alors horodaté — c'est lui qui pilote le retrait
438 + (``retired``) des sources disparues du journal depuis > RETIRED_AFTER_DAYS.
439 + """
420 440 row = rows.get(source)
421 441 previous_status = row.status if row is not None else None
422 442
@@ -425,6 +445,9 @@ def _apply(
425 445 row = ConnectorHealth(service=service, source=source)
426 446 session.add(row)
427 447 rows[source] = row
448 + row.last_seen = checked_at
449 + if seen:
450 + row.last_seen = checked_at
428 451 row.checked_at = checked_at
429 452 row.status = assessment.status
430 453 row.last_success = assessment.last_success
@@ -475,28 +498,57 @@ def _check_service(service: str, now: float) -> dict[str, Any]:
475 498 # de 2 h, fabrika en bord de rotation de ~11 h pour un seuil de 24 h).
476 499 source_stale_after = app_stale_after * (rotation_factor(payload, by_source) + 1)
477 500
501 + # Sources désactivées déclarées par l'app elle-même (signal explicite,
502 + # ex. ``disabled_stores`` de fabri-ka) : retrait immédiat.
503 + disabled = {
504 + str(s)
505 + for s in (payload.get("disabled_stores") or payload.get("disabled_sources") or [])
506 + if s
507 + }
508 +
478 509 with session_scope() as session:
479 510 rows = _load_rows(session, service)
480 511
481 512 assessment = assess_source(
482 513 [app_entry], _state_from_row(rows.get(APP_SOURCE)), now, app_stale_after
483 514 )
484 − counts[_apply(session, rows, service, APP_SOURCE, assessment, now)] += 1
515 + counts[_apply(session, rows, service, APP_SOURCE, assessment, now, seen=True)] += 1
485 516
486 517 for source, entries in by_source.items():
487 518 assessment = assess_source(
488 519 entries, _state_from_row(rows.get(source)), now, source_stale_after
489 520 )
490 − counts[_apply(session, rows, service, source, assessment, now)] += 1
521 + counts[_apply(session, rows, service, source, assessment, now, seen=True)] += 1
491 522
492 − # Sources connues mais absentes de la fenêtre courante : réévaluer la
493 − # staleness (le streak et la médiane restent inchangés).
523 + # Sources connues mais absentes de la fenêtre courante : retrait si
524 + # désactivées par l'app ou disparues du journal depuis trop longtemps,
525 + # sinon réévaluer la staleness (streak et médiane inchangés).
494 526 for source in list(rows):
495 527 if source == APP_SOURCE or source in by_source:
496 528 continue
497 − assessment = assess_source(
498 − [], _state_from_row(rows.get(source)), now, source_stale_after
499 − )
529 + prev = _state_from_row(rows.get(source))
530 + retired_reason = None
531 + if source in disabled:
532 + retired_reason = "désactivée par l'app (liste disabled du /api/stats)"
533 + elif prev.last_seen is not None and (
534 + now - prev.last_seen.timestamp() > RETIRED_AFTER_DAYS * 86400
535 + ):
536 + days = (now - prev.last_seen.timestamp()) / 86400
537 + retired_reason = (
538 + f"absente de tout journal de sync depuis {days:.0f} j "
539 + f"(seuil : {RETIRED_AFTER_DAYS} j)"
540 + )
541 + if retired_reason is not None:
542 + assessment = Assessment(
543 + status=STATUS_RETIRED,
544 + last_success=prev.last_success,
545 + found_last=prev.found_last,
546 + median_found=prev.median_found,
547 + consecutive_failures=prev.consecutive_failures,
548 + message=f"retirée de la supervision — {retired_reason}",
549 + )
550 + else:
551 + assessment = assess_source([], prev, now, source_stale_after)
500 552 counts[_apply(session, rows, service, source, assessment, now)] += 1
501 553
502 554 return {
modified tests/test_connector_health.py +1 −0
@@ -389,4 +389,5 @@ def test_endpoints_monitoring(clean_connector_table):
389 389 "degraded": 0,
390 390 "broken": 1,
391 391 "stale": 0,
392 + "retired": 0,
392 393 }
393 394