feat(errors, watchdog): Fehler-Stream mit Ignore-Regeln, Metrik-Verlauf, Abhängigkeits-Alarme

Fehler-Schnittstelle
- Neuer schlanker Eingang POST /api/errors/v1/report für den globalen
  Exception-Handler einer Anwendung. Titel und Dringlichkeit leitet der Server
  ab; gespeichert wird in derselben Tabelle wie der Bugtracker. Ein zweiter
  Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.
- error_level (fatal/error/warning) trennt die technische Art des Ereignisses
  von der geschäftlichen Dringlichkeit. Ein Duplicate-Entry ist technisch ein
  error, geschäftlich belanglos — beides zu vermischen war der Grund, warum
  solche Meldungen als Bug im Dashboard landeten.

Ignore-Regeln gegen bekanntes Rauschen
- bugtracker_ignore_rules mit contains/regex/exception_class, Pflichtfeld für
  die Begründung und optionaler Alarmschwelle.
- Ein Treffer bedeutet nicht "wegwerfen": Der Fehler wird weiterhin erfasst und
  hochgezählt, bleibt aber aus der Übersicht heraus und löst keine
  Benachrichtigung aus. Der Zähler ist der eigentliche Zweck — dass ein
  bekannter Fehler auftritt, ist normal; dass er plötzlich hundertmal so oft
  auftritt, ist ein Signal. Dafür das rollende Stundenfenster und
  error.rate_exceeded.
- Neue Regeln lassen sich rückwirkend auf bestehende Einträge anwenden.

Gruppierung überarbeitet
- Der Schlüssel nahm bisher 300 Zeichen Stacktrace auf. Derselbe Fehler
  zersplitterte dadurch, sobald ein Aufrufer den Stack einmal mitschickte und
  einmal nicht. Jetzt zählt der Ursprungsort: bevorzugt die Dateiangabe, sonst
  der erste Rahmen des Stacktrace.
- Die Normalisierung ersetzte nur Zahlen ab vier Stellen, wodurch
  'AA-1' und 'BB-2' getrennt blieben. Werte in Anführungszeichen, die Ziffern
  enthalten, gelten jetzt als veränderlich — der Schlüsselname bleibt erhalten,
  sodass verschiedene Unique-Keys unterscheidbar sind. Mit 9 Testfällen belegt.

Metrik-Verlauf
- watchdog_metrics speichert numerische Heartbeat-Werte mit Zeitstempel.
  Zuvor wurde metrics_json bei jedem Heartbeat überschrieben; damit ließ sich
  "die Platte läuft seit drei Tagen voll" nicht erkennen, nur "sie ist voll".
- GET /api/watchdog/v1/metrics liefert den verdichteten Verlauf und die
  Abweichung vom eigenen Sieben-Tage-Durchschnitt. Dieser relative Ansatz
  braucht keine projektspezifischen Schwellwerte.
- Aufbewahrung 14 Tage, Bereinigung stündlich durch den Evaluator.

Health-Checks per Push statt Abruf
- Der Heartbeat nimmt ein checks-Objekt entgegen, das die Anwendung selbst
  ermittelt. Das Deploymentcenter interpretiert die Namen nicht, es liest nur
  ok und message — was "gesund" bedeutet, entscheidet jede Anwendung selbst.
  Schlägt eine Prüfung fehl, wird ein als ok gemeldeter Heartbeat auf warning
  herabgestuft.
- Bewusst ausgehend: auf den Zielmaschinen müssen keine Ports geöffnet werden.

Abhängigkeitsbewusste Alarmierung
- Fällt ein Monitor aus, dessen Parent selbst unten ist, wird der Alarm
  unterdrückt. Der Zustand bleibt sichtbar. Vorher erzeugte ein ausgefallener
  Hypervisor mit zwölf VMs dreizehn Meldungen für ein Problem.
- Mehrere Ebenen und fehlerhafte Hierarchien (Zyklen, gelöschte Parents) sind
  abgesichert; mit 10 Testfällen belegt.

WebUI
- Neue Ansicht "Fehler-Stream" mit Filtern nach Projekt, Fehlerklasse,
  Umgebung, Zeitraum und Sichtbarkeit sowie Volltextsuche und Pagination.
  Stummgeschaltete Einträge sind standardmäßig ausgeblendet.
- Verwaltung der Ignore-Regeln inklusive Trefferzähler.
- Die Detailansicht zeigt Fehlerklasse, Stummschaltungsgrund und die Häufung
  im laufenden Stundenfenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 21:55:23 +02:00
co-authored by Claude Opus 5
parent a74c6fd990
commit 60e34b29f6
13 changed files with 2228 additions and 45 deletions
+1
View File
@@ -24,6 +24,7 @@ Options -Indexes
RewriteRule ^api/health/?$ public/api/health.php [L,QSA] RewriteRule ^api/health/?$ public/api/health.php [L,QSA]
RewriteRule ^api/openapi(?:\.json)?/?$ public/api/openapi.php [L,QSA] RewriteRule ^api/openapi(?:\.json)?/?$ public/api/openapi.php [L,QSA]
RewriteRule ^api/errors/v1/report/?$ public/api/errors/v1/report.php [L,QSA]
RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA] RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA]
RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA] RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA]
RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA]
+74 -1
View File
@@ -171,7 +171,80 @@ Zusätzlich stichprobenartig im WebUI prüfen:
--- ---
## 10. Optional: Webhooks ## 10. Fehler-Stream (Migration 007)
Neu ist eine eigene Schnittstelle für Laufzeitfehler, gedacht für den globalen
Exception-Handler einer Anwendung:
```
POST /api/errors/v1/report
```
Gespeichert wird in derselben Tabelle wie der Bugtracker — ein zweiter Speicher
wäre nur ein zweiter Ort, an dem man suchen müsste. Die Trennung von Rauschen
und Signal leisten stattdessen **Ignore-Regeln**.
### Bekannte, harmlose Fehler stummschalten
Im WebUI unter **Bugtracker → 🔇 Ignore-Regeln**. Ein Treffer bedeutet nicht
„wegwerfen": Der Fehler wird weiterhin erfasst und hochgezählt, bleibt aber aus
der Übersicht heraus und löst keine Benachrichtigung aus.
Der Zähler ist dabei der eigentliche Zweck. Zu jeder Regel lässt sich eine
Alarmschwelle hinterlegen — dass ein bekannter Fehler auftritt, ist normal;
dass er plötzlich hundertmal so oft auftritt, bedeutet, dass sich etwas
geändert hat.
Eine deaktivierte Vorlage für den Duplicate-Entry-Fall liegt bereits vor; sie
lässt sich im WebUI anpassen und einschalten.
### Gruppierung
Veränderliche Anteile werden beim Zusammenfassen ausgeblendet: Werte in
Anführungszeichen, die Ziffern enthalten, Speicheradressen, GUIDs, Zeitstempel
und Zeilennummern. `Duplicate entry 'MKT-88213'` und `Duplicate entry 'AA-1'`
landen damit in einer Gruppe — ein anderer Unique-Key dagegen nicht.
> **Hinweis:** Migration 007 ändert die Berechnung des Gruppenschlüssels.
> Bereits erfasste Einträge behalten ihren alten Schlüssel; ein erneut
> auftretender Fehler legt daher einmalig eine neue Gruppe an. Danach ist der
> Zustand wieder konsistent.
## 11. Metrik-Verlauf und Health-Checks
Numerische Werte aus dem Heartbeat-Feld `metrics` werden jetzt mit Zeitstempel
abgelegt (Aufbewahrung 14 Tage) und lassen sich über
`GET /api/watchdog/v1/metrics` abfragen. Zuvor wurde `metrics_json` bei jedem
Heartbeat überschrieben — es gab immer nur den letzten Moment.
Zusätzlich kann eine Anwendung ihren Gesundheitszustand selbst mitschicken:
```json
{ "source": "polytrader-worker", "status": "ok", "interval": 60,
"checks": {
"db": { "ok": true },
"market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" }
} }
```
Das Deploymentcenter interpretiert die Namen nicht — es liest nur `ok` und
`message`. Schlägt eine Prüfung fehl, wird ein als `ok` gemeldeter Heartbeat auf
`warning` herabgestuft. **Es müssen keine Ports geöffnet werden**, der Weg ist
ausgehend.
## 12. Abhängigkeitsbewusste Alarmierung
Fällt ein Monitor aus, für den `parent_source` gesetzt ist, und ist der
übergeordnete Monitor selbst unten, wird der Alarm für das Kind unterdrückt.
Der Zustand bleibt im Dashboard sichtbar.
Vorher erzeugte ein ausgefallener Hypervisor mit zwölf VMs dreizehn Meldungen
für ein Problem.
Damit das greift, muss die Hierarchie gepflegt sein — im WebUI unter
**WatchDog → System-Hierarchie** über das Feld *Übergeordnete Entität*.
## 13. Optional: Webhooks
Ereignisgesteuerte Benachrichtigung statt Polling. Ziel direkt in der Datenbank Ereignisgesteuerte Benachrichtigung statt Polling. Ziel direkt in der Datenbank
eintragen: eintragen:
+162
View File
@@ -0,0 +1,162 @@
<?php
/**
* POST /api/errors/v1/report
*
* Schlanker Eingang fuer Laufzeitfehler. Gedacht fuer den globalen
* Exception-Handler einer Anwendung: Es genuegen Ausnahmeklasse, Meldung und
* Stacktrace - Titel, Typ und Dringlichkeit leitet der Server ab.
*
* Gespeichert wird in derselben Tabelle wie der Bugtracker. Ein zweiter
* Speicher waere nur ein zweiter Ort, an dem man suchen muesste; die
* Trennung von Rauschen und Signal leisten stattdessen die Ignore-Regeln.
*
* Beispiel:
* curl -X POST https://dc.mhdf.de/api/errors/v1/report \
* -H "Authorization: Bearer $DC_TOKEN" \
* -H "Content-Type: application/json" \
* -d '{"project_slug":"polytrader",
* "exception":"PDOException",
* "message":"SQLSTATE[23000]: Duplicate entry ...",
* "stack_trace":"...",
* "build":"v2.0.1",
* "environment":"production"}'
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../src/bootstrap.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\License\RateLimiter;
Http::beginJson(['POST', 'OPTIONS'], true);
if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
}
$db = Db::init();
// Fehler treten in Schueben auf. Das Limit liegt bewusst hoeher als beim
// Bugtracker-Eingang, faengt aber eine Endlosschleife noch ab.
$limiter = new RateLimiter($db, (int)Config::get('bugtracker.error_rate', 300), 60, 'error_report');
if (!$limiter->check(Http::clientIp())) {
Http::fail(429, 'rate_limited', 'Zu viele Fehlermeldungen. Bitte Sendefrequenz reduzieren.');
}
$data = Http::body();
if ($data === []) {
Http::fail(400, 'empty_body', 'Der Request-Body ist leer.');
}
$environment = is_string($data['environment'] ?? null) ? $data['environment'] : null;
$context = ApiAuth::requireScope($db, 'bugtracker:report', $environment, false);
$projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
ApiAuth::enforceProject($context, $projectSlug);
$boundProject = ApiAuth::projectFilter($context);
if ($boundProject !== null) {
$projectSlug = $boundProject;
}
// --- Eingaben normalisieren ---
$exception = trimOrNull($data['exception'] ?? $data['exception_class'] ?? $data['type'] ?? null);
$message = trimOrNull($data['message'] ?? $data['error_message'] ?? null);
$stack = trimOrNull($data['stack_trace'] ?? $data['stacktrace'] ?? $data['trace'] ?? null);
if ($message === null && $exception === null) {
Http::fail(400, 'missing_error', 'Es wird mindestens "message" oder "exception" benoetigt.');
}
// fatal = Prozess beendet, error = Vorgang fehlgeschlagen, Programm laeuft weiter.
$level = is_string($data['level'] ?? $data['error_level'] ?? null)
? strtolower(trim($data['level'] ?? $data['error_level']))
: 'error';
if (!in_array($level, BugRepo::ERROR_LEVELS, true)) {
$level = 'error';
}
$repo = new BugRepo($db);
$result = $repo->reportItem([
'project_slug' => $projectSlug ?? 'default',
'type' => 'bug',
'title' => buildTitle($exception, $message),
'error_message' => $message,
'stack_trace' => $stack,
'exception' => $exception,
'error_level' => $level,
'severity' => $level === 'fatal' ? 'high' : 'medium',
'environment' => $environment ?? 'production',
'build_version' => trimOrNull($data['build'] ?? $data['build_version'] ?? null) ?? 'unknown',
'created_by' => $context['actor'],
'client_ref' => trimOrNull($data['client_ref'] ?? null) ?? Http::header('idempotency-key'),
// Optionaler Kontext - alles freiwillig
'repo_url' => trimOrNull($data['repo_url'] ?? null),
'git_branch' => trimOrNull($data['git_branch'] ?? null),
'commit_sha' => trimOrNull($data['commit_sha'] ?? null),
'file_path' => trimOrNull($data['file'] ?? $data['file_path'] ?? null),
'line_no' => $data['line'] ?? $data['line_no'] ?? null,
'tags' => trimOrNull($data['tags'] ?? null),
'context' => isset($data['context']) && is_array($data['context']) ? $data['context'] : null,
]);
$ignored = (bool)($result['ignored'] ?? false);
Http::ok([
'item_id' => $result['id'],
'is_new' => $result['is_new'],
'ignored' => $ignored,
'ignore_rule_id' => $result['ignore_rule_id'] ?? null,
'occurrence_count' => $result['occurrence_count'],
'error_level' => $level,
'rate_alerted' => (bool)($result['rate_alerted'] ?? false),
'message' => $ignored
? 'Als bekannt eingestuft, gezaehlt, nicht gemeldet.'
: ($result['is_new'] ? 'Fehler erfasst.' : 'Wiederkehrender Fehler, Zaehler erhoeht.'),
], $result['is_new'] ? 201 : 200);
// ======================================================================
/** @param mixed $value */
function trimOrNull($value): ?string
{
if ($value === null || is_array($value) || is_object($value)) {
return null;
}
$value = trim((string)$value);
return $value === '' ? null : $value;
}
/**
* Baut einen lesbaren Titel aus Ausnahmeklasse und Meldung.
*
* Die Meldung wird gekuerzt und von veraenderlichen Bestandteilen befreit,
* damit der Titel bei wiederkehrenden Fehlern stabil bleibt.
*/
function buildTitle(?string $exception, ?string $message): string
{
$summary = $message ?? '';
// Anfuehrungszeichen mit wechselndem Inhalt entfernen, z. B. konkrete
// Schluesselwerte in "Duplicate entry 'XY-123' for key ..."
$summary = preg_replace("/'[^']{0,80}'/", "'…'", $summary) ?? $summary;
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
if ($summary === '') {
return $exception ?? 'Unbehandelter Fehler';
}
if (mb_strlen($summary) > 180) {
$summary = mb_substr($summary, 0, 177) . '...';
}
return $exception !== null ? $exception . ': ' . $summary : $summary;
}
+56
View File
@@ -147,6 +147,62 @@ $spec = [
], ],
], ],
'/api/errors/v1/report' => [
'post' => [
'tags' => ['Fehler'],
'summary' => 'Laufzeitfehler melden',
'description' =>
"Schlanker Eingang fuer den globalen Exception-Handler. Titel und Dringlichkeit "
. "leitet der Server ab.\n\n"
. "Gleiche Fehler werden zu einer Gruppe zusammengefasst; veraenderliche Anteile "
. "(Werte in Anfuehrungszeichen mit Ziffern, Adressen, GUIDs, Zeitstempel) werden "
. "dabei ausgeblendet.\n\n"
. "Greift eine Ignore-Regel, kommt `\"ignored\": true` zurueck: der Fehler wird "
. "gezaehlt, aber nicht gemeldet. Ueberschreitet er die hinterlegte Alarmschwelle, "
. "meldet die Antwort `\"rate_alerted\": true`.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'project_slug' => ['type' => 'string'],
'exception' => ['type' => 'string', 'example' => 'PDOException'],
'message' => ['type' => 'string'],
'stack_trace' => ['type' => 'string'],
'level' => ['type' => 'string', 'enum' => BugRepo::ERROR_LEVELS, 'default' => 'error'],
'build' => ['type' => 'string'],
'environment' => ['type' => 'string', 'enum' => BugRepo::ENVIRONMENTS],
'file' => ['type' => 'string'],
'line' => ['type' => 'integer'],
'client_ref' => ['type' => 'string'],
'context' => ['type' => 'object'],
],
]]],
],
'responses' => [
'201' => ['description' => 'Neue Fehlergruppe angelegt'],
'200' => ['description' => 'Bestehende Gruppe hochgezaehlt'],
'401' => $errorResponse,
'429' => $errorResponse,
],
],
],
'/api/watchdog/v1/metrics' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Metrik-Verlauf abrufen',
'description' => 'Ohne "metric" die verfuegbaren Namen, mit "metric" den verdichteten Verlauf samt Abweichung vom eigenen Durchschnitt.',
'parameters' => [
['name' => 'source', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'metric', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'hours', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 24]],
['name' => 'bucket', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 15], 'description' => 'Fenstergroesse in Minuten'],
],
'responses' => ['200' => ['description' => 'Verlauf'], '401' => $errorResponse],
],
],
'/api/bugtracker/v1/projects' => [ '/api/bugtracker/v1/projects' => [
'get' => [ 'get' => [
'tags' => ['Bugtracker'], 'tags' => ['Bugtracker'],
+45 -4
View File
@@ -32,6 +32,7 @@ use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Watchdog\Evaluator; use Deploymentcenter\Modules\Watchdog\Evaluator;
use Deploymentcenter\Modules\Watchdog\EventLog; use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Modules\Watchdog\MetricStore;
use Deploymentcenter\Modules\Watchdog\MonitorRepo; use Deploymentcenter\Modules\Watchdog\MonitorRepo;
use Deploymentcenter\Modules\Watchdog\TokenManager as LegacyTokenManager; use Deploymentcenter\Modules\Watchdog\TokenManager as LegacyTokenManager;
@@ -56,18 +57,27 @@ switch ($action) {
authorizeSource($db, $source); authorizeSource($db, $source);
$instance = Http::str('instance') ?? 'default';
$metrics = Http::input('metrics');
$monitor = $monitorRepo->upsertHeartbeat( $monitor = $monitorRepo->upsertHeartbeat(
$source, $source,
Http::str('instance') ?? 'default', $instance,
Http::str('type') ?? 'heartbeat', Http::str('type') ?? 'heartbeat',
Http::int('interval', 0) ?: Http::int('expected_interval_sec', 60), Http::int('interval', 0) ?: Http::int('expected_interval_sec', 60),
Http::input('metrics'), $metrics,
strtolower(Http::str('status') ?? 'ok'), strtolower(Http::str('status') ?? 'ok'),
Http::str('message') ?? Http::str('reason'), Http::str('message') ?? Http::str('reason'),
Http::str('group') ?? Http::str('group_key'), Http::str('group') ?? Http::str('group_key'),
Http::str('os') Http::str('os'),
// Gesundheitszustand, den die Anwendung selbst ermittelt hat.
Http::input('checks')
); );
// Numerische Werte in den Verlauf uebernehmen, damit sich Trends
// erkennen lassen statt nur der letzte Moment.
$recordedMetrics = (new MetricStore($db))->record($source, $instance, $metrics);
// Zustandswechsel im Ereignisprotokoll festhalten. // Zustandswechsel im Ereignisprotokoll festhalten.
if (!empty($monitor['_state_changed'])) { if (!empty($monitor['_state_changed'])) {
$previous = (string)$monitor['_previous_state']; $previous = (string)$monitor['_previous_state'];
@@ -93,7 +103,9 @@ switch ($action) {
'last_status' => $monitor['last_status'], 'last_status' => $monitor['last_status'],
'last_seen_utc' => $monitor['last_seen_utc'], 'last_seen_utc' => $monitor['last_seen_utc'],
'state_changed' => (bool)($monitor['_state_changed'] ?? false), 'state_changed' => (bool)($monitor['_state_changed'] ?? false),
'failing_checks' => $monitor['_failing_checks'] ?? [],
], ],
'metrics_recorded' => $recordedMetrics,
]); ]);
case 'event': case 'event':
@@ -135,6 +147,34 @@ switch ($action) {
); );
Http::ok(['count' => count($events), 'events' => $events]); Http::ok(['count' => count($events), 'events' => $events]);
case 'metrics':
ApiAuth::requireScope($db, 'watchdog:read');
$source = Http::str('source');
if ($source === null) {
Http::fail(400, 'missing_source', 'Der Parameter "source" wird benoetigt.');
}
$store = new MetricStore($db);
$instance = Http::str('instance') ?? 'default';
$metricKey = Http::str('metric');
if ($metricKey === null) {
Http::ok([
'source' => $source,
'metrics' => $store->keysFor($source, $instance),
'hint' => 'Mit &metric=<name> den Verlauf abrufen.',
]);
}
Http::ok([
'source' => $source,
'metric' => $metricKey,
'hours' => Http::int('hours', 24),
'history' => $store->history($source, $metricKey, Http::int('hours', 24), $instance, Http::int('bucket', 15)),
'deviation' => $store->deviation($source, $metricKey, $instance),
]);
case 'evaluate': case 'evaluate':
// Bewusst nur fuer Shared Key oder eine angemeldete Sitzung - // Bewusst nur fuer Shared Key oder eine angemeldete Sitzung -
// ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben. // ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben.
@@ -149,7 +189,7 @@ switch ($action) {
default: default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
'available' => ['ping', 'event', 'status', 'events', 'evaluate'], 'available' => ['ping', 'event', 'status', 'events', 'metrics', 'evaluate'],
]); ]);
} }
@@ -213,6 +253,7 @@ function resolveWatchdogAction(): string
'events' => 'events', 'events' => 'events',
'status' => 'status', 'status' => 'status',
'evaluate' => 'evaluate', 'evaluate' => 'evaluate',
'metrics' => 'metrics',
default => 'status', default => 'status',
}; };
} }
+121
View File
@@ -377,6 +377,74 @@ else:
--- ---
## 8a. Fehler melden (Laufzeitfehler)
Für den globalen Exception-Handler einer Anwendung gibt es einen schlankeren
Eingang. Titel und Dringlichkeit leitet der Server ab:
```bash
curl -X POST https://dc.mhdf.de/api/errors/v1/report \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"project_slug": "polytrader",
"exception": "PDOException",
"message": "SQLSTATE[23000]: Duplicate entry '\''MKT-88213'\'' for key '\''uq_market'\''",
"stack_trace": "at Importer.php:142",
"level": "error",
"build": "v2.0.1",
"environment": "production",
"file": "src/Market/Importer.php",
"line": 142
}'
```
`level` unterscheidet die technische Art des Ereignisses — unabhängig von der
geschäftlichen Dringlichkeit:
| Wert | Bedeutung |
|---|---|
| `fatal` | Der Prozess hat sich beendet |
| `error` | Ein Vorgang ist fehlgeschlagen, das Programm läuft weiter (Vorgabe) |
| `warning` | Auffälligkeit ohne Funktionsverlust |
### Gruppierung
Gleiche Fehler werden zu einer Gruppe zusammengefasst und hochgezählt.
Veränderliche Bestandteile werden dabei ausgeblendet — Werte in
Anführungszeichen, die Ziffern enthalten, Speicheradressen, GUIDs, Zeitstempel
und Zeilennummern. Diese drei Meldungen ergeben **eine** Gruppe:
```
Duplicate entry 'MKT-88213' for key 'uq_market'
Duplicate entry 'AA-1' for key 'uq_market'
Duplicate entry 'X-99471' for key 'uq_market'
```
Ein anderer Unique-Key (`uq_orders`) bleibt dagegen eine eigene Gruppe — der
Schlüsselname enthält keine Ziffern und zählt damit zur Identität des Fehlers.
### Bekannte, harmlose Fehler
Manche Fehler treten betriebsbedingt auf und sind belanglos. Dafür gibt es
Ignore-Regeln, die im WebUI unter **Bugtracker → Ignore-Regeln** gepflegt
werden. Greift eine Regel, wird der Fehler weiterhin erfasst und **hochgezählt**,
bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung aus:
```json
{ "status": "success", "item_id": 42, "ignored": true,
"ignore_rule_id": 3, "occurrence_count": 3841, "rate_alerted": false,
"message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }
```
Der Zähler ist dabei der eigentliche Zweck: Zu jeder Regel lässt sich eine
Alarmschwelle hinterlegen. Dass ein bekannter Fehler auftritt, ist normal —
dass er plötzlich hundertmal so oft auftritt, ist ein Signal. Wird die Schwelle
überschritten, meldet die Antwort `"rate_alerted": true` und ein Webhook
`error.rate_exceeded` wird ausgelöst.
---
## 9. Watchdog-Heartbeat ## 9. Watchdog-Heartbeat
Läuft dein Agent als Dienst, melde dich regelmäßig: Läuft dein Agent als Dienst, melde dich regelmäßig:
@@ -393,6 +461,59 @@ curl -X POST https://dc.mhdf.de/api/watchdog/v1/ping \
stuft der Evaluator den Monitor nach dem Doppelten auf `warning` und nach dem stuft der Evaluator den Monitor nach dem Doppelten auf `warning` und nach dem
Vierfachen auf `down`. Vierfachen auf `down`.
### Eigenen Gesundheitszustand mitsenden
Ein Heartbeat beweist nur, dass ein Thread läuft — nicht, dass die Anwendung
ihre Arbeit tut. Deshalb kann sie ihren Zustand selbst mitschicken:
```json
{ "source": "polytrader-worker",
"status": "warning",
"interval": 60,
"checks": {
"db": { "ok": true },
"market_feed": { "ok": false, "message": "Letzter Tick vor 14 min" },
"queue": { "ok": true, "value": 23 }
},
"metrics": { "cpu": 18, "ram": 42, "queue_depth": 23 } }
```
Das Deploymentcenter interpretiert die Namen der Prüfungen **nicht** — es liest
nur `ok` und `message`. Was „gesund" bedeutet, entscheidet jede Anwendung
selbst. Schlägt eine Prüfung fehl, wird ein als `ok` gemeldeter Heartbeat auf
`warning` herabgestuft.
Der Weg ist bewusst ausgehend: Es müssen keine Ports auf den Zielmaschinen
geöffnet werden.
### Metriken
Numerische Werte aus `metrics` landen im Verlauf und lassen sich abfragen:
```bash
# Welche Metriken liefert dieser Monitor?
curl "https://dc.mhdf.de/api/watchdog/v1/metrics?source=polytrader-worker" \
-H "Authorization: Bearer $DC_TOKEN"
# Verlauf einer Metrik, auf 15-Minuten-Fenster verdichtet
curl "https://dc.mhdf.de/api/watchdog/v1/metrics?source=polytrader-worker&metric=queue_depth&hours=24" \
-H "Authorization: Bearer $DC_TOKEN"
```
Die Antwort enthält zusätzlich `deviation` — den Vergleich des aktuellen Werts
mit dem Durchschnitt der letzten sieben Tage desselben Monitors. Damit lassen
sich Auffälligkeiten erkennen, ohne für jedes Projekt Schwellwerte zu pflegen.
Verschachtelte Werte werden flach abgelegt: `{"cpu":{"load":1.2}}` wird zu
`cpu.load`. Rohwerte werden 14 Tage aufbewahrt.
### Abhängigkeiten
Ist bei einem Monitor `parent_source` gesetzt und fällt der übergeordnete
Monitor aus, werden Alarme für die Kinder unterdrückt. Ihr Zustand bleibt im
Dashboard sichtbar — es entsteht nur nicht für jede VM eines ausgefallenen
Hypervisors eine eigene Meldung.
--- ---
## 10. Verfügbarkeit prüfen ## 10. Verfügbarkeit prüfen
+578
View File
@@ -29,6 +29,7 @@ use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger; use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\TokenManager as CoreTokenManager; use Deploymentcenter\Core\TokenManager as CoreTokenManager;
use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Bugtracker\IgnoreRules;
use Deploymentcenter\Modules\License\Audit; use Deploymentcenter\Modules\License\Audit;
use Deploymentcenter\Modules\License\KeyGen; use Deploymentcenter\Modules\License\KeyGen;
use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\UpdateManager;
@@ -854,6 +855,94 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
dc_redirect('#tab-bugtracker'); dc_redirect('#tab-bugtracker');
} }
// ------------------------------------------------ Ignore-Regeln
case 'ir_create': {
$rules = new IgnoreRules($pdo);
try {
$ruleId = $rules->create(
trim((string)($_POST['project_slug'] ?? '')),
(string)($_POST['match_type'] ?? 'contains'),
(string)($_POST['pattern'] ?? ''),
(string)($_POST['reason'] ?? ''),
(int)($_POST['alert_on_rate'] ?? 0),
'user:' . $actor
);
} catch (InvalidArgumentException $e) {
dc_flash('Regel nicht angelegt: ' . e($e->getMessage()), 'danger');
dc_redirect('#sub-bugtracker-ignore');
}
// Bereits erfasste, passende Fehler gleich mit stummschalten -
// sonst muesste man sie einzeln nachpflegen.
$affected = 0;
if (!empty($_POST['apply_retroactively'])) {
$affected = $rules->applyRetroactively($ruleId);
}
dc_flash(sprintf(
'Regel #%d angelegt.%s',
$ruleId,
$affected > 0 ? sprintf(' %d bestehende(s) Item(s) wurden stummgeschaltet.', $affected) : ''
));
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_toggle': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
$enable = (int)($_POST['enable'] ?? 0) === 1;
if ($ruleId > 0 && (new IgnoreRules($pdo))->setEnabled($ruleId, $enable)) {
dc_flash(sprintf('Regel #%d %s.', $ruleId, $enable ? 'aktiviert' : 'deaktiviert'));
} else {
dc_flash('Regel nicht gefunden.', 'danger');
}
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_delete': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
if ($ruleId > 0 && (new IgnoreRules($pdo))->delete($ruleId)) {
// Items behalten ihren Status, verlieren aber die Bindung.
$pdo->prepare('UPDATE bugtracker_items SET ignore_rule_id = NULL WHERE ignore_rule_id = :id')
->execute([':id' => $ruleId]);
dc_flash(sprintf('Regel #%d geloescht.', $ruleId));
} else {
dc_flash('Regel nicht gefunden.', 'danger');
}
dc_redirect('#sub-bugtracker-ignore');
}
case 'ir_apply': {
$ruleId = (int)($_POST['rule_id'] ?? 0);
$affected = $ruleId > 0 ? (new IgnoreRules($pdo))->applyRetroactively($ruleId) : 0;
dc_flash(sprintf('%d bestehende(s) Item(s) durch Regel #%d stummgeschaltet.', $affected, $ruleId));
dc_redirect('#sub-bugtracker-ignore');
}
// Einzelnes Item stummschalten oder wieder aufnehmen
case 'bt_set_ignored': {
$itemId = (int)($_POST['item_id'] ?? 0);
$ignore = (int)($_POST['ignore'] ?? 1) === 1;
if ($itemId > 0) {
$ok = (new BugRepo($pdo))->updateItemDetails(
$itemId,
['status' => $ignore ? 'ignored' : 'open'],
'user:' . $actor
);
dc_flash(
$ok
? sprintf('Item #%d %s.', $itemId, $ignore ? 'stummgeschaltet' : 'wieder aufgenommen')
: 'Item nicht gefunden.',
$ok ? 'success' : 'danger'
);
}
dc_redirect('#sub-bugtracker-errors');
}
case 'bt_release_claim': { case 'bt_release_claim': {
$itemId = (int)($_POST['item_id'] ?? 0); $itemId = (int)($_POST['item_id'] ?? 0);
if ($itemId > 0) { if ($itemId > 0) {
@@ -1165,6 +1254,58 @@ function btUrl(array $overrides = []): string
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker'; return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#tab-bugtracker';
} }
// ----------------------------------------------------------------------
// Fehler-Stream
// ----------------------------------------------------------------------
$errPerPage = 40;
$errPage = max(1, (int)($_GET['err_page'] ?? 1));
$errShow = (string)($_GET['err_show'] ?? 'active'); // active | ignored | all
$errFilters = [
'project_slug' => (string)($_GET['err_project'] ?? 'all'),
'environment' => (string)($_GET['err_env'] ?? 'all'),
'error_level' => (string)($_GET['err_level'] ?? 'all'),
'search' => (string)($_GET['err_q'] ?? ''),
'since_hours' => (int)($_GET['err_hours'] ?? 0),
'errors_only' => true,
'order' => 'newest',
'limit' => $errPerPage,
'offset' => ($errPage - 1) * $errPerPage,
];
if ($errShow === 'ignored') {
$errFilters['only_ignored'] = true;
} elseif ($errShow === 'all') {
$errFilters['include_ignored'] = true;
}
$errorResult = $bugRepo->getItems($errFilters);
$errorItems = $errorResult['items'];
$errorTotal = $errorResult['total'];
$errorPages = max(1, (int)ceil($errorTotal / $errPerPage));
$ignoreRuleRepo = new IgnoreRules($pdo);
$ignoreRules = $ignoreRuleRepo->all();
/** Baut eine URL mit den aktuellen Fehler-Filtern. */
function errUrl(array $overrides = []): string
{
$params = [
'err_project' => $_GET['err_project'] ?? null,
'err_env' => $_GET['err_env'] ?? null,
'err_level' => $_GET['err_level'] ?? null,
'err_show' => $_GET['err_show'] ?? null,
'err_hours' => $_GET['err_hours'] ?? null,
'err_q' => $_GET['err_q'] ?? null,
'err_page' => $_GET['err_page'] ?? null,
];
$params = array_merge($params, $overrides);
$params = array_filter($params, static fn($v): bool => $v !== null && $v !== '' && $v !== 'all' && $v !== '0');
return 'index.php' . ($params !== [] ? '?' . http_build_query($params) : '') . '#sub-bugtracker-errors';
}
// Agenten-Uebersicht: wer bearbeitet gerade was // Agenten-Uebersicht: wer bearbeitet gerade was
$agentWorkload = $pdo->query(' $agentWorkload = $pdo->query('
SELECT claimed_by, SELECT claimed_by,
@@ -2811,6 +2952,418 @@ $csrfField = Csrf::field();
</div> </div>
</div> </div>
<!-- =============== Subtab: Fehler-Stream =============== -->
<div id="sub-bugtracker-errors" class="subtab-content">
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="stat-card">
<div class="stat-header">Fehler (24 h)</div>
<div class="stat-value" style="color:var(--warning);"><?= $bugtrackerStats['errors_24h'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">💀 Offene Abstürze</div>
<div class="stat-value" style="color:var(--danger);"><?= $bugtrackerStats['fatal_open'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">🔇 Stummgeschaltete Gruppen</div>
<div class="stat-value" style="color:var(--text-muted);"><?= $bugtrackerStats['ignored_groups'] ?></div>
</div>
<div class="stat-card">
<div class="stat-header">davon Vorkommnisse</div>
<div class="stat-value" style="color:var(--text-muted); font-size:1.5rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?>
</div>
</div>
</div>
<?php if ($bugtrackerStats['ignored_occurrences'] > 0): ?>
<div class="alert" style="background:rgba(91,157,255,0.10); border:1px solid rgba(91,157,255,0.25); color:#bfdbfe; font-weight:500; font-size:0.85rem;">
<?= number_format($bugtrackerStats['ignored_occurrences'], 0, ',', '.') ?> Vorkommnisse
wurden als bekannt eingestuft und aus dieser Ansicht herausgehalten ohne sie zu verwerfen.
Der Zähler bleibt erhalten, damit eine auffällige Häufung trotzdem auffällt.
</div>
<?php endif; ?>
<div class="card">
<div class="card-header" style="display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:0.75rem;">
<h2 class="card-title" style="margin:0;">
Fehler-Stream
<span class="badge badge-stopped" style="margin-left:0.5rem;"><?= (int)$errorTotal ?> Gruppen</span>
</h2>
<form method="GET" action="index.php" style="display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<select name="err_project" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<option value="all">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>" <?= $errFilters['project_slug'] === $p['slug'] ? 'selected' : '' ?>>
<?= e($p['name']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="err_level" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$levelOptions = [
'all' => '🎚️ Alle Klassen',
'fatal' => '💀 Absturz (fatal)',
'error' => '⚠️ Fehler (error)',
'warning' => '️ Warnung',
];
foreach ($levelOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['error_level'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_env" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$errEnvOptions = [
'all' => '🌍 Alle Umgebungen',
'production' => '🔴 Produktion',
'development' => '🔵 Entwicklung',
'staging' => '🟡 Staging',
'testing' => '🧪 Testing',
];
foreach ($errEnvOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errFilters['environment'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_show" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$showOptions = [
'active' => '🔔 Nur relevante',
'ignored' => '🔇 Nur stummgeschaltete',
'all' => '📋 Alle',
];
foreach ($showOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= $errShow === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<select name="err_hours" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="this.form.submit()">
<?php
$hourOptions = [
'0' => '🕒 Gesamter Zeitraum',
'1' => 'Letzte Stunde',
'24' => 'Letzte 24 Stunden',
'168' => 'Letzte 7 Tage',
'720' => 'Letzte 30 Tage',
];
foreach ($hourOptions as $value => $label): ?>
<option value="<?= e($value) ?>" <?= (string)$errFilters['since_hours'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="err_q" value="<?= e($errFilters['search']) ?>" class="form-input"
style="width:170px; padding:0.3rem 0.6rem; font-size:0.8rem;" placeholder="🔍 Suchen...">
<button type="submit" class="btn btn-sm">Filtern</button>
<a href="index.php#sub-bugtracker-errors" class="btn btn-sm btn-secondary">Zurücksetzen</a>
</form>
</div>
<table>
<thead>
<tr>
<th>Klasse</th>
<th>Fehler</th>
<th>Projekt / Umgebung</th>
<th>Build</th>
<th>Anzahl</th>
<th>Zuletzt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($errorItems === []): ?>
<tr>
<td colspan="7" style="text-align:center; padding:2.5rem; color:var(--text-muted);">
Keine Fehler für diese Filterkombination.
<?php if ($errShow === 'active'): ?>
<br><small>Stummgeschaltete Einträge sind ausgeblendet über „Nur stummgeschaltete“ sichtbar.</small>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php foreach ($errorItems as $item): ?>
<?php
$isIgnored = (string)$item['status'] === 'ignored';
$level = (string)($item['error_level'] ?? 'error');
$levelBadge = match ($level) {
'fatal' => '<span class="badge badge-down" style="font-weight:bold;">💀 FATAL</span>',
'warning' => '<span class="badge" style="background:rgba(91,157,255,0.2); color:#5b9dff; border:1px solid #5b9dff;">️ WARN</span>',
default => '<span class="badge badge-warning">⚠️ ERROR</span>',
};
// Rate im laufenden Fenster auffällige Häufung sichtbar machen
$windowCount = (int)($item['rate_window_count'] ?? 0);
$windowFresh = !empty($item['rate_window_start'])
&& (time() - (int)strtotime((string)$item['rate_window_start'] . ' UTC')) < 3600;
?>
<tr style="<?= $isIgnored ? 'opacity:0.55;' : '' ?>">
<td><?= $levelBadge ?></td>
<td>
<strong style="color:#fff;"><?= e(mb_strimwidth((string)$item['title'], 0, 90, '…')) ?></strong>
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:0.2rem; display:flex; flex-wrap:wrap; gap:0.4rem; align-items:center;">
<?php if ($isIgnored): ?>
<span class="badge" style="background:rgba(255,255,255,0.06); font-size:0.68rem;">
🔇 stummgeschaltet<?= !empty($item['ignore_rule_id']) ? ' (Regel #' . (int)$item['ignore_rule_id'] . ')' : '' ?>
</span>
<?php endif; ?>
<?php if ($windowFresh && $windowCount > 1): ?>
<span class="badge" style="background:rgba(245,166,35,0.18); color:#fbbf24; font-size:0.68rem;">
📈 <?= $windowCount ?>× in dieser Stunde
</span>
<?php endif; ?>
<?php if (!empty($item['file_path'])): ?>
<span style="font-family:monospace;">📄 <?= e((string)$item['file_path']) ?><?= !empty($item['line_no']) ? ':' . (int)$item['line_no'] : '' ?></span>
<?php endif; ?>
</div>
</td>
<td>
<code style="font-size:0.75rem;"><?= e((string)$item['project_slug']) ?></code>
<div style="font-size:0.7rem; color:var(--text-muted);"><?= e(strtoupper((string)$item['environment'])) ?></div>
</td>
<td><code style="font-size:0.75rem;"><?= e((string)($item['build_version'] ?? '—')) ?></code></td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$item['occurrence_count'], 0, ',', '.') ?>×
</span>
</td>
<td style="font-size:0.75rem; color:var(--text-muted);"><?= e(dc_ago($item['last_seen_at'] ?? null)) ?></td>
<td>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-secondary"
onclick="openBugtrackerModal(<?= (int)$item['id'] ?>)" title="Details">🔍</button>
<form method="POST" action="index.php#sub-bugtracker-errors" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="bt_set_ignored">
<input type="hidden" name="item_id" value="<?= (int)$item['id'] ?>">
<input type="hidden" name="ignore" value="<?= $isIgnored ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary"
title="<?= $isIgnored ? 'Wieder aufnehmen' : 'Stummschalten' ?>">
<?= $isIgnored ? '🔔' : '🔇' ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if ($errorPages > 1): ?>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:1rem; padding-top:1rem; border-top:1px solid var(--border-soft);">
<span style="font-size:0.8rem; color:var(--text-muted);">
Seite <?= (int)$errPage ?> von <?= (int)$errorPages ?> (<?= (int)$errorTotal ?> Gruppen)
</span>
<div style="display:flex; gap:0.4rem;">
<?php if ($errPage > 1): ?>
<a href="<?= e(errUrl(['err_page' => $errPage - 1])) ?>" class="btn btn-sm btn-secondary"> Zurück</a>
<?php endif; ?>
<?php if ($errPage < $errorPages): ?>
<a href="<?= e(errUrl(['err_page' => $errPage + 1])) ?>" class="btn btn-sm btn-secondary">Weiter </a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- =============== Subtab: Ignore-Regeln =============== -->
<div id="sub-bugtracker-ignore" class="subtab-content">
<div class="card">
<div class="card-header"><h2 class="card-title">🔇 Bekannte, harmlose Fehler</h2></div>
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem; max-width:70ch;">
Ein Treffer bedeutet nicht „wegwerfen“: Der Fehler wird weiterhin erfasst und
hochgezählt, bleibt aber aus der Übersicht heraus und löst keine Benachrichtigung
aus. Der Zähler ist dabei der eigentliche Gewinn dass ein bekannter Fehler
auftritt, ist normal; dass er plötzlich hundertmal so oft auftritt, ist ein Signal.
Dafür ist die Alarmschwelle da.
</p>
<form method="POST" action="index.php#sub-bugtracker-ignore">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_create">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Projekt</label>
<select name="project_slug" class="form-input">
<option value="">🌐 Alle Projekte</option>
<?php foreach ($projects as $p): ?>
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Vergleichsart</label>
<select name="match_type" class="form-input">
<option value="contains">Enthält (empfohlen)</option>
<option value="exception_class">Ausnahmeklasse exakt</option>
<option value="regex">Regulärer Ausdruck</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Alarm ab pro Stunde</label>
<input type="number" name="alert_on_rate" class="form-input" min="0" placeholder="z. B. 500">
<small style="font-size:0.7rem; color:var(--text-muted);">0 oder leer = nie alarmieren</small>
</div>
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Suchmuster</label>
<input type="text" name="pattern" class="form-input" required
placeholder="z. B. Duplicate entry">
</div>
<div class="form-group" style="margin-top:1rem;">
<label class="form-label">Begründung warum ist das harmlos?</label>
<textarea name="reason" class="form-input" rows="2" required
placeholder="z. B. Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benötigt, die Verarbeitung läuft normal weiter."></textarea>
<small style="font-size:0.7rem; color:var(--text-muted);">
Pflichtfeld: In sechs Monaten weiß sonst niemand mehr, warum hier weggeschaut wird.
</small>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; margin-top:1rem; font-size:0.85rem; cursor:pointer;">
<input type="checkbox" name="apply_retroactively" value="1" checked>
Bereits erfasste, passende Einträge gleich mit stummschalten
</label>
<button type="submit" class="btn" style="margin-top:1.25rem;">Regel anlegen</button>
</form>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Bestehende Regeln</h2></div>
<table>
<thead>
<tr>
<th>#</th>
<th>Projekt</th>
<th>Muster</th>
<th>Begründung</th>
<th>Alarmschwelle</th>
<th>Treffer</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php if ($ignoreRules === []): ?>
<tr>
<td colspan="8" style="text-align:center; padding:2rem; color:var(--text-muted);">
Noch keine Regeln angelegt.
</td>
</tr>
<?php endif; ?>
<?php foreach ($ignoreRules as $rule): ?>
<?php $enabled = !empty($rule['enabled']); ?>
<tr style="<?= $enabled ? '' : 'opacity:0.5;' ?>">
<td><?= (int)$rule['id'] ?></td>
<td>
<?php if (empty($rule['project_slug'])): ?>
<span class="badge badge-stopped">ALLE</span>
<?php else: ?>
<code style="font-size:0.75rem;"><?= e((string)$rule['project_slug']) ?></code>
<?php endif; ?>
</td>
<td>
<code style="font-size:0.78rem;"><?= e((string)$rule['pattern']) ?></code>
<div style="font-size:0.68rem; color:var(--text-muted);"><?= e((string)$rule['match_type']) ?></div>
</td>
<td style="font-size:0.78rem; color:var(--text-muted); max-width:320px;">
<?= e(mb_strimwidth((string)$rule['reason'], 0, 160, '…')) ?>
</td>
<td>
<?php if ($rule['alert_on_rate'] !== null): ?>
<span class="badge badge-warning"><?= (int)$rule['alert_on_rate'] ?>/h</span>
<?php else: ?>
<span style="color:var(--text-muted); font-size:0.8rem;"></span>
<?php endif; ?>
</td>
<td>
<span class="badge" style="background:rgba(255,255,255,0.08); font-family:monospace;">
<?= number_format((int)$rule['match_count'], 0, ',', '.') ?>
</span>
<div style="font-size:0.68rem; color:var(--text-muted);">
<?= (int)($rule['item_count'] ?? 0) ?> Item(s)
<?php if (!empty($rule['last_match_at'])): ?>
· <?= e(dc_ago((string)$rule['last_match_at'])) ?>
<?php endif; ?>
</div>
</td>
<td>
<span class="badge badge-<?= $enabled ? 'up' : 'stopped' ?>">
<?= $enabled ? 'AKTIV' : 'INAKTIV' ?>
</span>
</td>
<td>
<div style="display:flex; gap:0.3rem; flex-wrap:wrap;">
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_toggle">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<input type="hidden" name="enable" value="<?= $enabled ? '0' : '1' ?>">
<button type="submit" class="btn btn-sm btn-secondary">
<?= $enabled ? 'Deaktivieren' : 'Aktivieren' ?>
</button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_apply">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-secondary" title="Rückwirkend auf bestehende Einträge anwenden"></button>
</form>
<form method="POST" action="index.php#sub-bugtracker-ignore" style="display:inline"
onsubmit="return confirm('Regel wirklich löschen? Bereits stummgeschaltete Einträge bleiben stumm, verlieren aber die Zuordnung.');">
<?= $csrfField ?>
<input type="hidden" name="action" value="ir_delete">
<input type="hidden" name="rule_id" value="<?= (int)$rule['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">🗑️</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">💻 Anbindung</h2></div>
<p style="color:var(--text-muted); font-size:0.85rem; margin-bottom:1rem;">
Ein Aufruf, den man in den globalen Exception-Handler hängt. Titel und
Dringlichkeit leitet der Server ab, die Regeln oben greifen automatisch.
</p>
<div class="prompt-box">POST <?= e($baseUrl) ?>/api/errors/v1/report
Authorization: Bearer &lt;DC_TOKEN&gt;
Content-Type: application/json
{
"project_slug": "polytrader",
"exception": "PDOException",
"message": "SQLSTATE[23000]: Duplicate entry '...' for key 'uq_market'",
"stack_trace": "...",
"level": "error", // fatal = Prozess beendet
"build": "v2.0.1",
"environment": "production",
"file": "src/Market/Importer.php",
"line": 142
}
Antwort:
{ "status": "success", "item_id": 42, "ignored": true,
"occurrence_count": 3841, "rate_alerted": false,
"message": "Als bekannt eingestuft, gezaehlt, nicht gemeldet." }</div>
</div>
</div>
</div> </div>
<!-- ================= MODULE: TOKEN-VERWALTUNG ================= --> <!-- ================= MODULE: TOKEN-VERWALTUNG ================= -->
@@ -3226,6 +3779,8 @@ SYSTEM
], ],
'bugtracker': [ 'bugtracker': [
{ id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true }, { id: 'sub-bugtracker-items', label: '🐛 Bugs & Features', active: true },
{ id: 'sub-bugtracker-errors', label: '⚡ Fehler-Stream' },
{ id: 'sub-bugtracker-ignore', label: '🔇 Ignore-Regeln' },
{ id: 'sub-bugtracker-new', label: ' Item Anlegen' } { id: 'sub-bugtracker-new', label: ' Item Anlegen' }
], ],
'tokens': [ 'tokens': [
@@ -3399,6 +3954,26 @@ SYSTEM
}); });
} }
const levelBadge = item.error_level
? `<span class="badge badge-${item.error_level === 'fatal' ? 'down' : 'warning'}">${esc(item.error_level).toUpperCase()}</span>`
: '';
// Stummgeschaltet: Grund der Regel sichtbar machen, damit
// nicht geraten werden muss, warum hier weggeschaut wird.
const ignoreBanner = (item.status === 'ignored')
? `<div class="alert" style="background:rgba(139,147,167,0.15);border:1px solid rgba(139,147,167,0.3);color:#cbd5e1;padding:0.6rem 0.9rem;font-size:0.8rem;margin-bottom:1rem;">
🔇 Als bekannt eingestuft${item.ignore_rule_id ? ` durch Regel #${esc(item.ignore_rule_id)}` : ''}.
Wird weiter gezählt (${esc(item.occurrence_count)}×), löst aber keine Benachrichtigung aus.
</div>`
: '';
const rateInfo = (item.rate_window_count && Number(item.rate_window_count) > 1)
? `<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:1rem;">
📈 ${esc(item.rate_window_count)} Vorkommnisse im laufenden Stundenfenster
${item.rate_alerted_at ? `· zuletzt alarmiert ${esc(item.rate_alerted_at)} UTC` : ''}
</div>`
: '';
const claimBanner = (item.claimed_by && item.lease_until) const claimBanner = (item.claimed_by && item.lease_until)
? `<div class="alert" style="background:rgba(245,166,35,0.15);border:1px solid rgba(245,166,35,0.3);color:#fbbf24;padding:0.6rem 0.9rem;font-size:0.8rem;margin-bottom:1rem;"> ? `<div class="alert" style="background:rgba(245,166,35,0.15);border:1px solid rgba(245,166,35,0.3);color:#fbbf24;padding:0.6rem 0.9rem;font-size:0.8rem;margin-bottom:1rem;">
🤖 In Bearbeitung durch <strong>${esc(item.claimed_by)}</strong> bis ${esc(item.lease_until)} UTC 🤖 In Bearbeitung durch <strong>${esc(item.claimed_by)}</strong> bis ${esc(item.lease_until)} UTC
@@ -3426,6 +4001,7 @@ SYSTEM
<span class="badge badge-${item.type === 'feature_request' ? 'warning' : 'down'}">${esc(item.type).toUpperCase()}</span> <span class="badge badge-${item.type === 'feature_request' ? 'warning' : 'down'}">${esc(item.type).toUpperCase()}</span>
<span class="badge badge-stopped">${esc(item.environment).toUpperCase()}</span> <span class="badge badge-stopped">${esc(item.environment).toUpperCase()}</span>
<span class="badge badge-stopped">${esc(item.status).toUpperCase()}</span> <span class="badge badge-stopped">${esc(item.status).toUpperCase()}</span>
${levelBadge}
<h3 style="margin:0.5rem 0 0.2rem 0; color:#fff;">#${esc(item.id)}: ${esc(item.title)}</h3> <h3 style="margin:0.5rem 0 0.2rem 0; color:#fff;">#${esc(item.id)}: ${esc(item.title)}</h3>
<div style="font-size:0.8rem; color:var(--text-muted);"> <div style="font-size:0.8rem; color:var(--text-muted);">
Projekt: <code>${esc(item.project_slug)}</code> Projekt: <code>${esc(item.project_slug)}</code>
@@ -3437,7 +4013,9 @@ SYSTEM
<button type="button" class="btn btn-sm btn-secondary" onclick="closeBugtrackerModal()"></button> <button type="button" class="btn btn-sm btn-secondary" onclick="closeBugtrackerModal()"></button>
</div> </div>
${ignoreBanner}
${claimBanner} ${claimBanner}
${rateInfo}
${item.description ? ` ${item.description ? `
<div style="margin-bottom:1rem;"> <div style="margin-bottom:1rem;">
@@ -0,0 +1,101 @@
-- Migration 007: Fehler-Stream, Ignore-Regeln, Metrik-Verlauf, Health-Checks
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062.
-- ---------------------------------------------------------------------------
-- 1. Fehlerklasse getrennt vom Schweregrad
-- ---------------------------------------------------------------------------
-- severity beschreibt die geschaeftliche Dringlichkeit (low ... critical),
-- error_level die technische Art des Ereignisses. Ein "Duplicate entry" ist
-- technisch ein error, geschaeftlich aber belanglos - beides zu vermischen
-- war der Grund, warum solche Meldungen bisher als Bug im Dashboard landeten.
ALTER TABLE bugtracker_items
ADD COLUMN error_level ENUM('fatal','error','warning') NULL AFTER severity;
-- Status "ignored": bekannt, harmlos, wird weiter gezaehlt, aber nicht gemeldet.
ALTER TABLE bugtracker_items
MODIFY COLUMN status ENUM('open','planned','in_progress','resolved','closed','rejected','ignored')
NOT NULL DEFAULT 'open';
-- Rollendes Stundenfenster fuer die Ratenerkennung. Interessant ist nicht,
-- DASS ein bekannter Fehler auftritt, sondern wenn er ploetzlich viel
-- haeufiger auftritt.
ALTER TABLE bugtracker_items ADD COLUMN rate_window_start DATETIME NULL AFTER occurrence_count;
ALTER TABLE bugtracker_items ADD COLUMN rate_window_count INT NOT NULL DEFAULT 0 AFTER rate_window_start;
ALTER TABLE bugtracker_items ADD COLUMN rate_alerted_at DATETIME NULL AFTER rate_window_count;
-- Welche Regel hat dieses Item stummgeschaltet?
ALTER TABLE bugtracker_items ADD COLUMN ignore_rule_id INT NULL AFTER error_level;
ALTER TABLE bugtracker_items ADD KEY ix_bt_error_level (error_level, last_seen_at);
-- ---------------------------------------------------------------------------
-- 2. Ignore-Regeln
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS bugtracker_ignore_rules (
id INT AUTO_INCREMENT PRIMARY KEY,
project_slug VARCHAR(64) NULL, -- NULL = gilt fuer alle Projekte
match_type ENUM('contains','regex','exception_class') NOT NULL DEFAULT 'contains',
pattern VARCHAR(255) NOT NULL,
-- Warum ist das harmlos? Pflichtfeld, damit in sechs Monaten noch
-- nachvollziehbar ist, weshalb hier weggeschaut wird.
reason TEXT NOT NULL,
-- Ab wie vielen Vorkommnissen pro Stunde soll trotzdem alarmiert werden?
-- NULL = nie alarmieren.
alert_on_rate INT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
match_count BIGINT NOT NULL DEFAULT 0,
last_match_at DATETIME NULL,
created_by VARCHAR(100) NOT NULL DEFAULT 'admin',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_ignore_project (project_slug, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 3. Metrik-Verlauf
-- ---------------------------------------------------------------------------
-- Bisher wurde metrics_json bei jedem Heartbeat ueberschrieben - es gab immer
-- nur den letzten Moment. Damit laesst sich "Platte laeuft seit drei Tagen
-- voll" nicht erkennen, sondern nur "Platte ist voll".
CREATE TABLE IF NOT EXISTS watchdog_metrics (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source VARCHAR(100) NOT NULL,
instance VARCHAR(100) NOT NULL DEFAULT 'default',
metric_key VARCHAR(64) NOT NULL,
metric_value DOUBLE NOT NULL,
recorded_utc DATETIME NOT NULL,
KEY ix_metric_lookup (source, instance, metric_key, recorded_utc),
KEY ix_metric_time (recorded_utc)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 4. Health-Checks per Push
-- ---------------------------------------------------------------------------
-- Die Anwendung sendet ihren Gesundheitszustand im Heartbeat mit. Das
-- Deploymentcenter interpretiert die Namen der Pruefungen nicht - es liest
-- nur ok und message. Damit muessen auf den Zielmaschinen keine Ports
-- geoeffnet werden.
ALTER TABLE watchdog_monitors ADD COLUMN health_json JSON NULL AFTER metrics_json;
ALTER TABLE watchdog_monitors ADD COLUMN failing_checks VARCHAR(255) NULL AFTER health_json;
-- Alarme fuer Kinder unterdruecken, solange der Parent unten ist.
ALTER TABLE watchdog_monitors ADD COLUMN alert_suppressed_by VARCHAR(100) NULL AFTER failing_checks;
-- Aufraeumauftrag fuer den Metrik-Verlauf
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled) VALUES
('metrics_cleanup', 86400, 1)
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec);
-- ---------------------------------------------------------------------------
-- 5. Beispielregel, damit der Aufbau sofort erkennbar ist
-- ---------------------------------------------------------------------------
-- Bewusst deaktiviert (enabled = 0): sie dient als Vorlage und greift erst,
-- wenn sie im WebUI bewusst eingeschaltet wird.
INSERT INTO bugtracker_ignore_rules
(project_slug, match_type, pattern, reason, alert_on_rate, enabled, created_by)
SELECT 'polytrader', 'contains', 'Duplicate entry',
'Marktdaten kommen doppelt aus dem Feed. Der Eintrag wird ohnehin nur einmal benoetigt, die Verarbeitung laeuft normal weiter. Alarm erst bei auffaelliger Haeufung.',
500, 0, 'system:vorlage'
WHERE NOT EXISTS (
SELECT 1 FROM bugtracker_ignore_rules WHERE project_slug = 'polytrader' AND pattern = 'Duplicate entry'
);
+330 -21
View File
@@ -30,11 +30,20 @@ final class BugRepo
public const TYPES = ['bug', 'feature_request']; public const TYPES = ['bug', 'feature_request'];
public const ENVIRONMENTS = ['production', 'development', 'staging', 'testing']; public const ENVIRONMENTS = ['production', 'development', 'staging', 'testing'];
public const SEVERITIES = ['idea', 'wishlist', 'low', 'medium', 'high', 'critical']; public const SEVERITIES = ['idea', 'wishlist', 'low', 'medium', 'high', 'critical'];
public const STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected']; public const STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'closed', 'rejected', 'ignored'];
/**
* Technische Art des Ereignisses, unabhaengig von der Dringlichkeit.
* Ein "Duplicate entry" ist technisch ein error, geschaeftlich belanglos.
*/
public const ERROR_LEVELS = ['fatal', 'error', 'warning'];
/** Status, in denen ein Item als offen/aktiv gilt. */ /** Status, in denen ein Item als offen/aktiv gilt. */
public const ACTIVE_STATUSES = ['open', 'planned', 'in_progress']; public const ACTIVE_STATUSES = ['open', 'planned', 'in_progress'];
/** Laenge des rollenden Fensters fuer die Ratenerkennung, in Minuten. */
private const RATE_WINDOW_MINUTES = 60;
private const DEFAULT_LIMIT = 100; private const DEFAULT_LIMIT = 100;
private const MAX_LIMIT = 500; private const MAX_LIMIT = 500;
@@ -77,6 +86,17 @@ final class BugRepo
$createdBy = self::text($data['created_by'] ?? null) ?? 'agent'; $createdBy = self::text($data['created_by'] ?? null) ?? 'agent';
$clientRef = self::text($data['client_ref'] ?? null); $clientRef = self::text($data['client_ref'] ?? null);
$exceptionClass = self::text($data['exception'] ?? $data['exception_class'] ?? null);
$errorLevel = self::oneOfOrNull($data['error_level'] ?? null, self::ERROR_LEVELS);
// Bekannte, harmlose Fehler werden erfasst und gezaehlt, aber nicht
// gemeldet. Der Zaehler bleibt erhalten, damit eine auffaellige
// Haeufung trotzdem auffaellt.
$ignoreRules = new IgnoreRules($this->db);
$ignoreRule = $type === 'bug'
? $ignoreRules->match($projectSlug, $exceptionClass, $errorMessage, $title)
: null;
// Strukturierter Code-Kontext // Strukturierter Code-Kontext
$repoUrl = self::text($data['repo_url'] ?? null); $repoUrl = self::text($data['repo_url'] ?? null);
$gitBranch = self::text($data['git_branch'] ?? null); $gitBranch = self::text($data['git_branch'] ?? null);
@@ -107,11 +127,26 @@ final class BugRepo
} }
// --- 2. Deduplizierung ueber einen stabilen Schluessel --- // --- 2. Deduplizierung ueber einen stabilen Schluessel ---
$dedupKey = self::buildDedupKey($type, $projectSlug, $environment, $title, $errorMessage, $stackTrace); $dedupKey = self::buildDedupKey(
$type,
$projectSlug,
$environment,
$title,
$errorMessage,
$stackTrace,
$exceptionClass,
$filePath
);
$duplicate = $this->findByDedupKey($dedupKey); $duplicate = $this->findByDedupKey($dedupKey);
if ($duplicate !== null && in_array($duplicate['status'], self::ACTIVE_STATUSES, true)) { // Auch stummgeschaltete Items werden weiter hochgezaehlt.
return $this->registerRecurrence($duplicate, $pushId, $buildVersion, $severity); $countable = array_merge(self::ACTIVE_STATUSES, ['ignored']);
if ($duplicate !== null && in_array($duplicate['status'], $countable, true)) {
if ($ignoreRule !== null) {
$ignoreRules->recordMatch((int)$ignoreRule['id']);
}
return $this->registerRecurrence($duplicate, $pushId, $buildVersion, $severity, $ignoreRule);
} }
// Bereits geloest und tritt erneut auf: neues Item mit Verweis auf das alte. // Bereits geloest und tritt erneut auf: neues Item mit Verweis auf das alte.
@@ -148,19 +183,23 @@ final class BugRepo
// --- 3. Neues Item anlegen --- // --- 3. Neues Item anlegen ---
$projectId = $this->projectIdForSlug($projectSlug); $projectId = $this->projectIdForSlug($projectSlug);
$initialStatus = $ignoreRule !== null ? 'ignored' : 'open';
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO bugtracker_items ( INSERT INTO bugtracker_items (
project_id, project_slug, type, title, description, project_id, project_slug, type, title, description,
error_message, stack_trace, error_hash, dedup_key, error_message, stack_trace, error_hash, dedup_key,
build_version, repo_url, git_branch, commit_sha, file_path, line_no, context_json, build_version, repo_url, git_branch, commit_sha, file_path, line_no, context_json,
environment, severity, status, occurrence_count, environment, severity, error_level, ignore_rule_id, status, occurrence_count,
rate_window_start, rate_window_count,
push_id, target_agent, tags, client_ref, regression_of, push_id, target_agent, tags, client_ref, regression_of,
first_seen_at, last_seen_at, created_by, created_at first_seen_at, last_seen_at, created_by, created_at
) VALUES ( ) VALUES (
:project_id, :project_slug, :type, :title, :description, :project_id, :project_slug, :type, :title, :description,
:error_message, :stack_trace, :error_hash, :dedup_key, :error_message, :stack_trace, :error_hash, :dedup_key,
:build_version, :repo_url, :git_branch, :commit_sha, :file_path, :line_no, :context_json, :build_version, :repo_url, :git_branch, :commit_sha, :file_path, :line_no, :context_json,
:environment, :severity, "open", 1, :environment, :severity, :error_level, :ignore_rule_id, :status, 1,
UTC_TIMESTAMP(), 1,
:push_id, :target_agent, :tags, :client_ref, :regression_of, :push_id, :target_agent, :tags, :client_ref, :regression_of,
UTC_TIMESTAMP(), UTC_TIMESTAMP(), :created_by, UTC_TIMESTAMP() UTC_TIMESTAMP(), UTC_TIMESTAMP(), :created_by, UTC_TIMESTAMP()
) )
@@ -186,6 +225,9 @@ final class BugRepo
':context_json' => $contextJson, ':context_json' => $contextJson,
':environment' => $environment, ':environment' => $environment,
':severity' => $severity, ':severity' => $severity,
':error_level' => $errorLevel,
':ignore_rule_id' => $ignoreRule !== null ? (int)$ignoreRule['id'] : null,
':status' => $initialStatus,
':push_id' => $pushId, ':push_id' => $pushId,
':target_agent' => $targetAgent, ':target_agent' => $targetAgent,
':tags' => $tags, ':tags' => $tags,
@@ -222,6 +264,40 @@ final class BugRepo
$newId = (int)$this->db->lastInsertId(); $newId = (int)$this->db->lastInsertId();
if ($ignoreRule !== null) {
$ignoreRules->recordMatch((int)$ignoreRule['id']);
$this->addComment(
$newId,
'system',
sprintf(
"Automatisch stummgeschaltet durch Regel #%d (%s: \"%s\").\n\nBegruendung: %s",
$ignoreRule['id'],
$ignoreRule['match_type'],
$ignoreRule['pattern'],
$ignoreRule['reason']
),
'auto_ignored'
);
// Bewusst keine Benachrichtigung: genau dafuer ist die Regel da.
return [
'id' => $newId,
'is_new' => true,
'idempotent_hit' => false,
'occurrence_count' => 1,
'dedup_key' => $dedupKey,
'error_hash' => $dedupKey,
'type' => $type,
'status' => 'ignored',
'ignored' => true,
'ignore_rule_id' => (int)$ignoreRule['id'],
'environment' => $environment,
'push_id' => $pushId,
'regression_of' => $regressionOf,
];
}
$note = $type === 'bug' $note = $type === 'bug'
? 'Bug im System erfasst.' ? 'Bug im System erfasst.'
: ($severity === 'idea' ? 'Neue Idee hinterlegt.' : 'Feature-Request eingereicht.'); : ($severity === 'idea' ? 'Neue Idee hinterlegt.' : 'Feature-Request eingereicht.');
@@ -232,13 +308,15 @@ final class BugRepo
'environment' => $environment, 'environment' => $environment,
'project_slug' => $projectSlug, 'project_slug' => $projectSlug,
'title' => $title, 'title' => $title,
'error_level' => $errorLevel,
]); ]);
if ($severity === 'critical') { if ($severity === 'critical' || $errorLevel === 'fatal') {
$this->notify('bug.critical', $newId, [ $this->notify('bug.critical', $newId, [
'project_slug' => $projectSlug, 'project_slug' => $projectSlug,
'title' => $title, 'title' => $title,
'environment' => $environment, 'environment' => $environment,
'error_level' => $errorLevel,
]); ]);
} }
@@ -251,26 +329,54 @@ final class BugRepo
'error_hash' => $type === 'bug' ? $dedupKey : null, 'error_hash' => $type === 'bug' ? $dedupKey : null,
'type' => $type, 'type' => $type,
'status' => 'open', 'status' => 'open',
'ignored' => false,
'environment' => $environment, 'environment' => $environment,
'push_id' => $pushId, 'push_id' => $pushId,
'regression_of' => $regressionOf, 'regression_of' => $regressionOf,
]; ];
} }
/** Zaehlt ein wiederkehrendes Vorkommnis hoch. */ /**
private function registerRecurrence(array $existing, ?string $pushId, string $build, string $severity): array * Zaehlt ein wiederkehrendes Vorkommnis hoch und pflegt das Ratenfenster.
{ *
* @param array<string,mixed>|null $ignoreRule
*/
private function registerRecurrence(
array $existing,
?string $pushId,
string $build,
string $severity,
?array $ignoreRule = null
): array {
$itemId = (int)$existing['id'];
$isIgnored = (string)$existing['status'] === 'ignored';
$newCount = (int)$existing['occurrence_count'] + 1; $newCount = (int)$existing['occurrence_count'] + 1;
// Eskalation: ein bereits offener Bug, der erneut mit hoeherem // Eskalation: ein bereits offener Bug, der erneut mit hoeherem
// Schweregrad gemeldet wird, wird hochgestuft - nie herabgestuft. // Schweregrad gemeldet wird, wird hochgestuft - nie herabgestuft.
// Fuer stummgeschaltete Items entfaellt das; dort entscheidet die Rate.
$currentRank = array_search((string)$existing['severity'], self::SEVERITIES, true); $currentRank = array_search((string)$existing['severity'], self::SEVERITIES, true);
$incomingRank = array_search($severity, self::SEVERITIES, true); $incomingRank = array_search($severity, self::SEVERITIES, true);
$escalate = is_int($currentRank) && is_int($incomingRank) && $incomingRank > $currentRank; $escalate = !$isIgnored
&& is_int($currentRank)
&& is_int($incomingRank)
&& $incomingRank > $currentRank;
// Reihenfolge beachten: rate_window_count muss den alten Wert von
// rate_window_start sehen und steht deshalb davor.
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
UPDATE bugtracker_items UPDATE bugtracker_items
SET occurrence_count = :count, SET rate_window_count = IF(
rate_window_start IS NULL
OR rate_window_start < UTC_TIMESTAMP() - INTERVAL ' . self::RATE_WINDOW_MINUTES . ' MINUTE,
1, rate_window_count + 1
),
rate_window_start = IF(
rate_window_start IS NULL
OR rate_window_start < UTC_TIMESTAMP() - INTERVAL ' . self::RATE_WINDOW_MINUTES . ' MINUTE,
UTC_TIMESTAMP(), rate_window_start
),
occurrence_count = :count,
last_seen_at = UTC_TIMESTAMP(), last_seen_at = UTC_TIMESTAMP(),
push_id = COALESCE(:push_id, push_id), push_id = COALESCE(:push_id, push_id),
build_version = COALESCE(:build, build_version), build_version = COALESCE(:build, build_version),
@@ -283,20 +389,22 @@ final class BugRepo
':build' => $build, ':build' => $build,
':escalate' => $escalate ? 1 : 0, ':escalate' => $escalate ? 1 : 0,
':severity' => $severity, ':severity' => $severity,
':id' => (int)$existing['id'], ':id' => $itemId,
]); ]);
if ($escalate) { if ($escalate) {
$this->addComment( $this->addComment(
(int)$existing['id'], $itemId,
'system', 'system',
sprintf('Schweregrad automatisch hochgestuft: %s -> %s (erneutes Auftreten).', $existing['severity'], $severity), sprintf('Schweregrad automatisch hochgestuft: %s -> %s (erneutes Auftreten).', $existing['severity'], $severity),
'severity_escalated' 'severity_escalated'
); );
} }
$rateAlerted = $this->checkRateThreshold($itemId, $ignoreRule, $existing);
return [ return [
'id' => (int)$existing['id'], 'id' => $itemId,
'is_new' => false, 'is_new' => false,
'idempotent_hit' => false, 'idempotent_hit' => false,
'occurrence_count' => $newCount, 'occurrence_count' => $newCount,
@@ -304,11 +412,95 @@ final class BugRepo
'error_hash' => $existing['error_hash'], 'error_hash' => $existing['error_hash'],
'type' => $existing['type'], 'type' => $existing['type'],
'status' => $existing['status'], 'status' => $existing['status'],
'ignored' => $isIgnored,
'rate_alerted' => $rateAlerted,
'environment' => $existing['environment'], 'environment' => $existing['environment'],
'push_id' => $pushId ?? $existing['push_id'], 'push_id' => $pushId ?? $existing['push_id'],
]; ];
} }
/**
* Prueft, ob ein stummgeschalteter Fehler auffaellig haeufig auftritt.
*
* Das ist der eigentliche Grund, warum bekannte Fehler weiter gezaehlt und
* nicht verworfen werden: Dass ein Duplicate-Entry auftritt, ist normal.
* Dass er ploetzlich hundertmal so oft auftritt, bedeutet, dass sich am
* Datenfeed etwas geaendert hat.
*
* @param array<string,mixed>|null $ignoreRule
* @param array<string,mixed> $existing
*/
private function checkRateThreshold(int $itemId, ?array $ignoreRule, array $existing): bool
{
$threshold = null;
if ($ignoreRule !== null && $ignoreRule['alert_on_rate'] !== null) {
$threshold = (int)$ignoreRule['alert_on_rate'];
} elseif (!empty($existing['ignore_rule_id'])) {
// Regel ueber das Item nachschlagen, wenn sie nicht mitgeliefert wurde.
$lookup = $this->db->prepare('SELECT alert_on_rate, pattern, reason FROM bugtracker_ignore_rules WHERE id = :id');
$lookup->execute([':id' => (int)$existing['ignore_rule_id']]);
$row = $lookup->fetch();
if (is_array($row) && $row['alert_on_rate'] !== null) {
$threshold = (int)$row['alert_on_rate'];
$ignoreRule = $row;
}
}
if ($threshold === null || $threshold <= 0) {
return false;
}
$stmt = $this->db->prepare('
SELECT rate_window_count, rate_alerted_at, rate_window_start, project_slug, title
FROM bugtracker_items WHERE id = :id
');
$stmt->execute([':id' => $itemId]);
$current = $stmt->fetch();
if (!is_array($current) || (int)$current['rate_window_count'] < $threshold) {
return false;
}
// Nur einmal je Fenster alarmieren, sonst entsteht genau der
// Meldungssturm, den die Regel verhindern soll.
if ($current['rate_alerted_at'] !== null
&& $current['rate_window_start'] !== null
&& $current['rate_alerted_at'] >= $current['rate_window_start']) {
return false;
}
$this->db->prepare('UPDATE bugtracker_items SET rate_alerted_at = UTC_TIMESTAMP() WHERE id = :id')
->execute([':id' => $itemId]);
$message = sprintf(
'Auffaellige Haeufung: %d Vorkommnisse in %d Minuten (Schwelle %d). '
. 'Der Fehler gilt als bekannt und harmlos, tritt aber deutlich haeufiger auf als erwartet.',
(int)$current['rate_window_count'],
self::RATE_WINDOW_MINUTES,
$threshold
);
$this->addComment($itemId, 'system', $message, 'rate_exceeded');
$this->notify('error.rate_exceeded', $itemId, [
'project_slug' => $current['project_slug'],
'title' => $current['title'],
'count' => (int)$current['rate_window_count'],
'threshold' => $threshold,
'window_min' => self::RATE_WINDOW_MINUTES,
]);
Logger::warning('Ratenschwelle eines stummgeschalteten Fehlers ueberschritten', [
'item_id' => $itemId,
'count' => (int)$current['rate_window_count'],
'threshold' => $threshold,
]);
return true;
}
// ================================================================== // ==================================================================
// Abfragen // Abfragen
// ================================================================== // ==================================================================
@@ -399,8 +591,15 @@ final class BugRepo
} }
} }
// status und severity duerfen als Liste kommen: status=open,in_progress // status, severity und error_level duerfen als Liste kommen,
foreach (['status' => self::STATUSES, 'severity' => self::SEVERITIES] as $key => $allowed) { // z. B. status=open,in_progress
$listFilters = [
'status' => self::STATUSES,
'severity' => self::SEVERITIES,
'error_level' => self::ERROR_LEVELS,
];
foreach ($listFilters as $key => $allowed) {
$value = $filters[$key] ?? null; $value = $filters[$key] ?? null;
if ($value === null || $value === '' || $value === 'all') { if ($value === null || $value === '' || $value === 'all') {
continue; continue;
@@ -438,6 +637,33 @@ final class BugRepo
$where[] = '(lease_until IS NULL OR lease_until < UTC_TIMESTAMP())'; $where[] = '(lease_until IS NULL OR lease_until < UTC_TIMESTAMP())';
} }
// Stummgeschaltete Fehler bleiben standardmaessig aussen vor. Sie sind
// bekannt und harmlos - genau deshalb sollen sie die Uebersicht nicht
// fuellen. Wer sie sehen will, fragt sie ausdruecklich an.
$includeIgnored = isset($filters['include_ignored'])
&& filter_var($filters['include_ignored'], FILTER_VALIDATE_BOOLEAN);
$onlyIgnored = isset($filters['only_ignored'])
&& filter_var($filters['only_ignored'], FILTER_VALIDATE_BOOLEAN);
$statusRequested = ($filters['status'] ?? 'all') !== 'all' && ($filters['status'] ?? '') !== '';
if ($onlyIgnored) {
$where[] = 'status = "ignored"';
} elseif (!$includeIgnored && !$statusRequested) {
$where[] = 'status <> "ignored"';
}
// Nur Eintraege, die ueber die Fehler-Schnittstelle kamen.
if (!empty($filters['errors_only'])) {
$where[] = 'error_level IS NOT NULL';
}
// Zeitraum, z. B. "letzte 24 Stunden"
$sinceHours = isset($filters['since_hours']) ? (int)$filters['since_hours'] : 0;
if ($sinceHours > 0) {
$where[] = 'last_seen_at > (UTC_TIMESTAMP() - INTERVAL ' . min($sinceHours, 8760) . ' HOUR)';
}
// Delta-Abfrage fuer Polling. // Delta-Abfrage fuer Polling.
$since = $filters['updated_since'] ?? null; $since = $filters['updated_since'] ?? null;
if (is_string($since) && trim($since) !== '') { if (is_string($since) && trim($since) !== '') {
@@ -759,6 +985,19 @@ final class BugRepo
$params[':line_no'] = is_numeric($updates['line_no']) ? (int)$updates['line_no'] : null; $params[':line_no'] = is_numeric($updates['line_no']) ? (int)$updates['line_no'] : null;
} }
if (array_key_exists('error_level', $updates)) {
$fields[] = 'error_level = :error_level';
$params[':error_level'] = self::oneOfOrNull($updates['error_level'], self::ERROR_LEVELS);
}
// Wird ein stummgeschaltetes Item wieder geoeffnet, verliert es die
// Regelbindung - sonst wuerde das naechste Vorkommnis es sofort
// wieder stummschalten.
if (isset($updates['status']) && $updates['status'] !== 'ignored'
&& (string)($existing['status'] ?? '') === 'ignored') {
$fields[] = 'ignore_rule_id = NULL';
}
if ($fields === []) { if ($fields === []) {
return true; return true;
} }
@@ -902,7 +1141,17 @@ final class BugRepo
SUM(status = "resolved") AS resolved_total, SUM(status = "resolved") AS resolved_total,
SUM(type = "bug" AND severity = "critical" AND status IN ("open","in_progress")) AS critical_bugs, SUM(type = "bug" AND severity = "critical" AND status IN ("open","in_progress")) AS critical_bugs,
SUM(claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()) AS in_progress_by_agents, SUM(claimed_by IS NOT NULL AND lease_until > UTC_TIMESTAMP()) AS in_progress_by_agents,
SUM(status IN ("open","planned","in_progress")) AS open_total SUM(status IN ("open","planned","in_progress")) AS open_total,
-- Fehler-Stream
SUM(error_level = "fatal" AND status IN ("open","in_progress")) AS fatal_open,
SUM(error_level IS NOT NULL AND status = "ignored") AS ignored_groups,
-- Nur was noch offen ist: bereits abgehakte oder stummgeschaltete
-- Gruppen sind keine aktuellen Fehler mehr.
SUM(error_level IS NOT NULL
AND status IN ("open","planned","in_progress")
AND last_seen_at > UTC_TIMESTAMP() - INTERVAL 24 HOUR) AS errors_24h,
COALESCE(SUM(CASE WHEN status = "ignored" THEN occurrence_count ELSE 0 END), 0) AS ignored_occurrences
FROM bugtracker_items' . $where; FROM bugtracker_items' . $where;
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
@@ -912,6 +1161,7 @@ final class BugRepo
$keys = [ $keys = [
'open_bugs_prod', 'open_bugs_dev', 'open_features', 'ideas_count', 'open_bugs_prod', 'open_bugs_dev', 'open_features', 'ideas_count',
'resolved_total', 'critical_bugs', 'in_progress_by_agents', 'open_total', 'resolved_total', 'critical_bugs', 'in_progress_by_agents', 'open_total',
'fatal_open', 'ignored_groups', 'errors_24h', 'ignored_occurrences',
]; ];
$stats = []; $stats = [];
@@ -995,14 +1245,17 @@ final class BugRepo
string $environment, string $environment,
string $title, string $title,
?string $errorMessage, ?string $errorMessage,
?string $stackTrace ?string $stackTrace,
?string $exceptionClass = null,
?string $filePath = null
): string { ): string {
if ($type === 'bug') { if ($type === 'bug') {
$signature = implode('|', [ $signature = implode('|', [
$projectSlug, $projectSlug,
$environment, $environment,
$exceptionClass !== null ? mb_strtolower(trim($exceptionClass)) : '',
self::normalizeForHash($errorMessage ?? $title), self::normalizeForHash($errorMessage ?? $title),
self::normalizeForHash(mb_substr($stackTrace ?? '', 0, 300)), self::originOf($stackTrace, $filePath),
]); ]);
} else { } else {
$signature = implode('|', [ $signature = implode('|', [
@@ -1015,6 +1268,39 @@ final class BugRepo
return substr(hash('sha256', $signature), 0, 40); return substr(hash('sha256', $signature), 0, 40);
} }
/**
* Stabile Herkunftsangabe fuer den Deduplizierungsschluessel.
*
* Zuvor flossen 300 Zeichen Stacktrace ein. Damit zersplitterte derselbe
* Fehler in mehrere Gruppen, sobald ein Aufrufer den Stack einmal mitschickte
* und einmal nicht - oder wenn er aus unterschiedlicher Aufruftiefe kam.
* Jetzt zaehlt nur der Ursprungsort: bevorzugt die ausdrueckliche
* Dateiangabe, sonst der erste verwertbare Rahmen des Stacktrace.
*/
private static function originOf(?string $stackTrace, ?string $filePath): string
{
if ($filePath !== null && trim($filePath) !== '') {
// Zeilennummern verschieben sich bei jeder Aenderung an der Datei;
// die Datei selbst bleibt dieselbe Fehlerquelle.
return mb_strtolower(trim($filePath));
}
if ($stackTrace === null || trim($stackTrace) === '') {
return '';
}
foreach (preg_split('/\r?\n/', trim($stackTrace)) ?: [] as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
// Erster nicht leerer Rahmen, von veraenderlichen Anteilen befreit.
return self::normalizeForHash(mb_substr($line, 0, 200));
}
return '';
}
/** /**
* Entfernt Rauschen, das denselben Fehler sonst als neu erscheinen liesse: * Entfernt Rauschen, das denselben Fehler sonst als neu erscheinen liesse:
* Zeilennummern, Speicheradressen, Zeitstempel, GUIDs, Mehrfach-Leerzeichen. * Zeilennummern, Speicheradressen, Zeitstempel, GUIDs, Mehrfach-Leerzeichen.
@@ -1022,12 +1308,24 @@ final class BugRepo
private static function normalizeForHash(string $value): string private static function normalizeForHash(string $value): string
{ {
$value = mb_strtolower(trim($value)); $value = mb_strtolower(trim($value));
$patterns = [ $patterns = [
'/0x[0-9a-f]+/' => '0xADDR', '/0x[0-9a-f]+/' => '0xADDR',
'/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/' => 'GUID', '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/' => 'GUID',
'/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}/' => 'TIMESTAMP', '/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}/' => 'TIMESTAMP',
// Werte in Anfuehrungszeichen, die mindestens eine Ziffer
// enthalten, sind praktisch immer konkrete Datensatzwerte und
// gehoeren nicht zur Identitaet des Fehlers:
// Duplicate entry 'MKT-88213' for key 'uq_market'
// Der Schluesselname bleibt erhalten, weil er ohne Ziffern
// auskommt - unterschiedliche Unique-Keys bleiben damit
// unterscheidbar.
"/'[^']*\d[^']*'/" => "'VALUE'",
'/"[^"]*\d[^"]*"/' => '"VALUE"',
'/:line \d+/' => ':line N', '/:line \d+/' => ':line N',
'/\b\d{4,}\b/' => 'N', '/\b\d{3,}\b/' => 'N',
'/\s+/' => ' ', '/\s+/' => ' ',
]; ];
@@ -1072,4 +1370,15 @@ final class BugRepo
} }
return $default; return $default;
} }
/**
* Wie oneOf(), liefert aber null statt eines Standardwerts.
*
* @param mixed $value
* @param list<string> $allowed
*/
private static function oneOfOrNull($value, array $allowed): ?string
{
return is_string($value) && in_array($value, $allowed, true) ? $value : null;
}
} }
+278
View File
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Bugtracker;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Regeln fuer bekannte, harmlose Fehler.
*
* Anwendungen erzeugen betriebsbedingt Fehler, die niemanden interessieren -
* etwa doppelte Datensaetze aus einem Datenfeed, die ohnehin nur einmal
* gebraucht werden. Ohne Klassifizierung landen sie im Dashboard und
* verdraengen dort das Wesentliche.
*
* Ein Treffer bedeutet nicht "wegwerfen": Das Item wird angelegt und weiter
* hochgezaehlt, bleibt aber im Status "ignored" und loest keine
* Benachrichtigung aus. Der Zaehler ist der eigentliche Gewinn - denn
* interessant ist nicht, dass ein bekannter Fehler auftritt, sondern wenn er
* ploetzlich um ein Vielfaches haeufiger auftritt.
*/
final class IgnoreRules
{
public const MATCH_TYPES = ['contains', 'regex', 'exception_class'];
/** @var array<string,list<array<string,mixed>>>|null Regeln je Projekt, pro Request zwischengespeichert */
private static ?array $cache = null;
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Sucht die erste zutreffende Regel.
*
* @return array<string,mixed>|null
*/
public function match(string $projectSlug, ?string $exceptionClass, ?string $message, ?string $title = null): ?array
{
$haystack = trim(implode("\n", array_filter([$exceptionClass, $message, $title])));
if ($haystack === '') {
return null;
}
foreach ($this->rulesFor($projectSlug) as $rule) {
if (self::ruleMatches($rule, $exceptionClass, $haystack)) {
return $rule;
}
}
return null;
}
/** @param array<string,mixed> $rule */
private static function ruleMatches(array $rule, ?string $exceptionClass, string $haystack): bool
{
$pattern = (string)$rule['pattern'];
if ($pattern === '') {
return false;
}
switch ((string)$rule['match_type']) {
case 'exception_class':
return $exceptionClass !== null
&& strcasecmp(trim($exceptionClass), $pattern) === 0;
case 'regex':
// Das Muster stammt aus der Verwaltungsoberflaeche und wird als
// reiner Ausdruck ohne Begrenzer gespeichert. Der Begrenzer wird
// hier gesetzt, damit kein eigener Modifikator untergeschoben
// werden kann.
$delimited = '/' . str_replace('/', '\/', $pattern) . '/i';
$result = @preg_match($delimited, $haystack);
if ($result === false) {
Logger::warning('Ignore-Regel enthaelt einen ungueltigen regulaeren Ausdruck', [
'rule_id' => $rule['id'] ?? null,
'pattern' => $pattern,
]);
return false;
}
return $result === 1;
case 'contains':
default:
return stripos($haystack, $pattern) !== false;
}
}
/**
* Regeln des Projekts plus die projektuebergreifenden.
*
* @return list<array<string,mixed>>
*/
private function rulesFor(string $projectSlug): array
{
if (self::$cache === null) {
self::$cache = [];
try {
$rows = $this->db->query('
SELECT * FROM bugtracker_ignore_rules
WHERE enabled = 1
ORDER BY project_slug IS NULL ASC, id ASC
')->fetchAll() ?: [];
foreach ($rows as $row) {
$key = $row['project_slug'] !== null && $row['project_slug'] !== ''
? (string)$row['project_slug']
: '*';
self::$cache[$key][] = $row;
}
} catch (\Throwable $e) {
// Tabelle fehlt (Migration noch nicht gelaufen): dann greift
// eben keine Regel. Kein Grund, die Erfassung scheitern zu lassen.
Logger::warning('Ignore-Regeln nicht ladbar', ['error' => $e->getMessage()]);
self::$cache = [];
}
}
return array_merge(
self::$cache[$projectSlug] ?? [],
self::$cache['*'] ?? []
);
}
/** Vermerkt einen Treffer fuer die Statistik in der Verwaltungsansicht. */
public function recordMatch(int $ruleId): void
{
try {
$stmt = $this->db->prepare('
UPDATE bugtracker_ignore_rules
SET match_count = match_count + 1, last_match_at = UTC_TIMESTAMP()
WHERE id = :id
');
$stmt->execute([':id' => $ruleId]);
} catch (\Throwable $e) {
Logger::warning('Treffer der Ignore-Regel nicht vermerkt', ['rule_id' => $ruleId]);
}
}
// ------------------------------------------------------------------
// Verwaltung
// ------------------------------------------------------------------
/** @return list<array<string,mixed>> */
public function all(): array
{
$stmt = $this->db->query('
SELECT r.*,
(SELECT COUNT(*) FROM bugtracker_items i WHERE i.ignore_rule_id = r.id) AS item_count
FROM bugtracker_ignore_rules r
ORDER BY r.enabled DESC, r.last_match_at DESC, r.id DESC
');
return $stmt->fetchAll() ?: [];
}
public function create(
?string $projectSlug,
string $matchType,
string $pattern,
string $reason,
?int $alertOnRate,
string $createdBy
): int {
$pattern = trim($pattern);
$reason = trim($reason);
if ($pattern === '') {
throw new \InvalidArgumentException('Das Suchmuster darf nicht leer sein.');
}
if ($reason === '') {
throw new \InvalidArgumentException('Bitte begruenden, warum dieser Fehler harmlos ist.');
}
if (!in_array($matchType, self::MATCH_TYPES, true)) {
$matchType = 'contains';
}
// Ungueltige Ausdruecke sollen beim Anlegen auffallen, nicht spaeter
// still bei jedem eingehenden Fehler.
if ($matchType === 'regex' && @preg_match('/' . str_replace('/', '\/', $pattern) . '/i', '') === false) {
throw new \InvalidArgumentException('Der regulaere Ausdruck ist ungueltig.');
}
$stmt = $this->db->prepare('
INSERT INTO bugtracker_ignore_rules
(project_slug, match_type, pattern, reason, alert_on_rate, enabled, created_by, created_at)
VALUES (:slug, :match_type, :pattern, :reason, :rate, 1, :created_by, UTC_TIMESTAMP())
');
$stmt->execute([
':slug' => $projectSlug !== null && $projectSlug !== '' && $projectSlug !== 'all' ? $projectSlug : null,
':match_type' => $matchType,
':pattern' => $pattern,
':reason' => $reason,
':rate' => $alertOnRate !== null && $alertOnRate > 0 ? $alertOnRate : null,
':created_by' => $createdBy,
]);
self::$cache = null;
return (int)$this->db->lastInsertId();
}
public function setEnabled(int $ruleId, bool $enabled): bool
{
$stmt = $this->db->prepare('UPDATE bugtracker_ignore_rules SET enabled = :enabled WHERE id = :id');
$stmt->execute([':enabled' => $enabled ? 1 : 0, ':id' => $ruleId]);
self::$cache = null;
return $stmt->rowCount() > 0;
}
public function delete(int $ruleId): bool
{
$stmt = $this->db->prepare('DELETE FROM bugtracker_ignore_rules WHERE id = :id');
$stmt->execute([':id' => $ruleId]);
self::$cache = null;
return $stmt->rowCount() > 0;
}
/**
* Wendet eine neu angelegte Regel rueckwirkend an: passende offene Items
* werden stummgeschaltet. Ohne das muesste man sie einzeln nachpflegen.
*
* @return int Anzahl der betroffenen Items
*/
public function applyRetroactively(int $ruleId): int
{
$stmt = $this->db->prepare('SELECT * FROM bugtracker_ignore_rules WHERE id = :id');
$stmt->execute([':id' => $ruleId]);
$rule = $stmt->fetch();
if (!is_array($rule)) {
return 0;
}
$where = ['status IN ("open","planned","in_progress")'];
$params = [];
if ($rule['project_slug'] !== null && $rule['project_slug'] !== '') {
$where[] = 'project_slug = :slug';
$params[':slug'] = $rule['project_slug'];
}
$candidates = $this->db->prepare(
'SELECT id, title, error_message FROM bugtracker_items WHERE ' . implode(' AND ', $where) . ' LIMIT 2000'
);
$candidates->execute($params);
$affected = 0;
$update = $this->db->prepare('
UPDATE bugtracker_items
SET status = "ignored", ignore_rule_id = :rule_id
WHERE id = :id
');
foreach ($candidates->fetchAll() ?: [] as $item) {
$haystack = trim(($item['error_message'] ?? '') . "\n" . ($item['title'] ?? ''));
if ($haystack === '' || !self::ruleMatches($rule, null, $haystack)) {
continue;
}
$update->execute([':rule_id' => $ruleId, ':id' => (int)$item['id']]);
$affected++;
}
return $affected;
}
}
+109
View File
@@ -9,6 +9,9 @@ use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Notify\WebhookDispatcher; use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO; use PDO;
// MetricStore und MonitorRepo liegen im selben Namensraum und werden
// automatisch geladen.
/** /**
* Watchdog-Evaluator. * Watchdog-Evaluator.
* *
@@ -50,8 +53,21 @@ final class Evaluator
$candidates = $monitorRepo->getEvaluationCandidates(); $candidates = $monitorRepo->getEvaluationCandidates();
// Zustaende aller Monitore, damit die Abhaengigkeitspruefung ohne
// weitere Abfragen auskommt.
$stateBySource = [];
$parentBySource = [];
foreach ($candidates as $monitor) {
$source = (string)$monitor['source'];
$stateBySource[$source] = (string)$monitor['state'];
$parentBySource[$source] = !empty($monitor['parent_source'])
? (string)$monitor['parent_source']
: null;
}
$changes = []; $changes = [];
$checked = 0; $checked = 0;
$suppressed = [];
foreach ($candidates as $monitor) { foreach ($candidates as $monitor) {
$checked++; $checked++;
@@ -66,6 +82,10 @@ final class Evaluator
$reason = self::reasonFor($monitor, $target); $reason = self::reasonFor($monitor, $target);
$monitorRepo->setState((int)$monitor['id'], $target, $reason); $monitorRepo->setState((int)$monitor['id'], $target, $reason);
// Zustandswechsel im Bild mitfuehren, damit ein spaeter geprueftes
// Kind den frisch gefallenen Parent bereits sieht.
$stateBySource[(string)$monitor['source']] = $target;
$eventLog->logEvent( $eventLog->logEvent(
(string)$monitor['source'], (string)$monitor['source'],
(string)$monitor['instance'], (string)$monitor['instance'],
@@ -76,13 +96,34 @@ final class Evaluator
$reason $reason
); );
// Faellt ein Hypervisor aus, sind seine VMs zwangslaeufig auch weg.
// Ohne diese Pruefung erzeugt ein einzelner Ausfall so viele
// Meldungen, wie Kinder daran haengen - bei einem Node mit zwoelf
// VMs also dreizehn Alarme fuer ein Problem.
$blockingParent = self::findDownAncestor(
(string)$monitor['source'],
$parentBySource,
$stateBySource
);
$changes[] = [ $changes[] = [
'source' => $monitor['source'], 'source' => $monitor['source'],
'from' => $current, 'from' => $current,
'to' => $target, 'to' => $target,
'reason' => $reason, 'reason' => $reason,
'suppressed' => $blockingParent,
]; ];
$monitorRepo->setAlertSuppression((int)$monitor['id'], $blockingParent);
if ($blockingParent !== null) {
$suppressed[] = [
'source' => $monitor['source'],
'parent' => $blockingParent,
];
continue;
}
// Stummgeschaltete Monitore erscheinen im Dashboard, loesen aber // Stummgeschaltete Monitore erscheinen im Dashboard, loesen aber
// keine Benachrichtigung aus. // keine Benachrichtigung aus.
if (empty($monitor['is_muted'])) { if (empty($monitor['is_muted'])) {
@@ -99,6 +140,12 @@ final class Evaluator
Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]); Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
} }
// Alten Metrik-Verlauf abraeumen (hoechstens einmal pro Stunde).
$purgedMetrics = 0;
if (self::shouldPurgeMetrics($db)) {
$purgedMetrics = (new MetricStore($db))->purge();
}
$durationMs = (int)round((microtime(true) - $started) * 1000); $durationMs = (int)round((microtime(true) - $started) * 1000);
self::recordRun($db, $durationMs, count($changes)); self::recordRun($db, $durationMs, count($changes));
@@ -110,11 +157,73 @@ final class Evaluator
'checked' => $checked, 'checked' => $checked,
'changed' => count($changes), 'changed' => count($changes),
'changes' => $changes, 'changes' => $changes,
'suppressed' => $suppressed,
'released_leases' => $releasedLeases, 'released_leases' => $releasedLeases,
'purged_metrics' => $purgedMetrics,
'duration_ms' => $durationMs, 'duration_ms' => $durationMs,
]; ];
} }
/**
* Sucht den naechsten Vorfahren, der selbst unten ist.
*
* @param array<string,?string> $parentBySource
* @param array<string,string> $stateBySource
* @return string|null Name des ausgefallenen Vorfahren, oder null
*/
private static function findDownAncestor(
string $source,
array $parentBySource,
array $stateBySource
): ?string {
$seen = [];
$current = $parentBySource[$source] ?? null;
// Tiefenbegrenzung und Zyklusschutz: ein falsch gesetzter Parent darf
// keine Endlosschleife ausloesen.
while ($current !== null && !isset($seen[$current]) && count($seen) < 10) {
$seen[$current] = true;
$parentState = $stateBySource[$current] ?? null;
if ($parentState === 'down' || $parentState === 'error') {
return $current;
}
$current = $parentBySource[$current] ?? null;
}
return null;
}
/** Der Verlauf wird hoechstens stuendlich bereinigt, nicht bei jedem Lauf. */
private static function shouldPurgeMetrics(PDO $db): bool
{
try {
$stmt = $db->query("
SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'metrics_cleanup'
");
$lastRun = $stmt !== false ? $stmt->fetchColumn() : false;
if ($lastRun === false || $lastRun === null) {
$due = true;
} else {
$due = (time() - (int)strtotime((string)$lastRun . ' UTC')) > 3600;
}
if ($due) {
$db->prepare('
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, enabled)
VALUES ("metrics_cleanup", 86400, UTC_TIMESTAMP(), 1)
ON DUPLICATE KEY UPDATE last_run_utc = UTC_TIMESTAMP()
')->execute();
}
return $due;
} catch (\Throwable $e) {
return false;
}
}
/** /**
* Ermittelt den Zustand, den ein Monitor haben sollte. * Ermittelt den Zustand, den ein Monitor haben sollte.
* null bedeutet: keine Aenderung noetig. * null bedeutet: keine Aenderung noetig.
+270
View File
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Verlauf der Heartbeat-Metriken.
*
* Bisher wurde metrics_json bei jedem Heartbeat ueberschrieben - es gab immer
* nur den letzten Moment. Damit laesst sich "die Platte laeuft seit drei Tagen
* voll" nicht erkennen, sondern nur "die Platte ist voll".
*
* Bewusst schmal gehalten: Das hier ist die Datengrundlage fuer Sparklines und
* Abweichungsalarme im Dashboard, keine Zeitreihendatenbank. Wer echte Analyse
* braucht, ist mit Prometheus besser bedient.
*/
final class MetricStore
{
/** Aufbewahrung der Rohwerte in Tagen. */
private const RETENTION_DAYS = 14;
/** Maximale Anzahl Metriken je Heartbeat - schuetzt vor Ausreissern. */
private const MAX_KEYS_PER_BEAT = 25;
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
/**
* Nimmt die numerischen Werte eines Heartbeats auf.
* Nicht numerische Werte werden uebergangen.
*
* @param mixed $metrics
*/
public function record(string $source, string $instance, $metrics): int
{
if (!is_array($metrics) && !is_object($metrics)) {
return 0;
}
$flat = self::flatten((array)$metrics);
if ($flat === []) {
return 0;
}
try {
$stmt = $this->db->prepare('
INSERT INTO watchdog_metrics (source, instance, metric_key, metric_value, recorded_utc)
VALUES (:source, :instance, :metric_key, :metric_value, UTC_TIMESTAMP())
');
$written = 0;
foreach ($flat as $key => $value) {
if ($written >= self::MAX_KEYS_PER_BEAT) {
break;
}
$stmt->execute([
':source' => mb_substr($source, 0, 100),
':instance' => mb_substr($instance, 0, 100),
':metric_key' => mb_substr($key, 0, 64),
':metric_value' => $value,
]);
$written++;
}
return $written;
} catch (\Throwable $e) {
// Der Verlauf ist Beiwerk; ein Heartbeat darf daran nicht scheitern.
Logger::warning('Metriken nicht gespeichert', [
'source' => $source,
'error' => $e->getMessage(),
]);
return 0;
}
}
/**
* Verlauf einer Metrik, auf Zeitfenster verdichtet.
*
* @return list<array{bucket:string,avg:float,min:float,max:float,samples:int}>
*/
public function history(
string $source,
string $metricKey,
int $hours = 24,
string $instance = 'default',
int $bucketMinutes = 15
): array {
$hours = max(1, min($hours, 24 * self::RETENTION_DAYS));
$bucketMinutes = max(1, min($bucketMinutes, 1440));
try {
// Zeitstempel auf das Raster runden, damit gleichmaessige
// Stuetzstellen entstehen.
$stmt = $this->db->prepare('
SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(recorded_utc) / (' . $bucketMinutes . ' * 60))
* (' . $bucketMinutes . ' * 60)) AS bucket,
AVG(metric_value) AS avg_value,
MIN(metric_value) AS min_value,
MAX(metric_value) AS max_value,
COUNT(*) AS samples
FROM watchdog_metrics
WHERE source = :source
AND instance = :instance
AND metric_key = :metric_key
AND recorded_utc > (UTC_TIMESTAMP() - INTERVAL ' . $hours . ' HOUR)
GROUP BY bucket
ORDER BY bucket ASC
');
$stmt->execute([
':source' => $source,
':instance' => $instance,
':metric_key' => $metricKey,
]);
$out = [];
foreach ($stmt->fetchAll() ?: [] as $row) {
$out[] = [
'bucket' => (string)$row['bucket'],
'avg' => (float)$row['avg_value'],
'min' => (float)$row['min_value'],
'max' => (float)$row['max_value'],
'samples' => (int)$row['samples'],
];
}
return $out;
} catch (\Throwable $e) {
Logger::warning('Metrik-Verlauf nicht abrufbar', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Welche Metriken liefert dieser Monitor ueberhaupt?
*
* @return list<string>
*/
public function keysFor(string $source, string $instance = 'default'): array
{
try {
$stmt = $this->db->prepare('
SELECT DISTINCT metric_key
FROM watchdog_metrics
WHERE source = :source AND instance = :instance
AND recorded_utc > (UTC_TIMESTAMP() - INTERVAL 7 DAY)
ORDER BY metric_key ASC
');
$stmt->execute([':source' => $source, ':instance' => $instance]);
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
} catch (\Throwable $e) {
return [];
}
}
/**
* Vergleicht den aktuellen Wert mit dem eigenen Verlauf.
*
* Dieser Ansatz braucht kein Projektwissen: Statt fester Schwellwerte je
* Anwendung wird gemeldet, was deutlich vom bisherigen Verhalten desselben
* Monitors abweicht.
*
* @return array{deviates:bool,current:float,baseline:float,factor:float}|null
*/
public function deviation(string $source, string $metricKey, string $instance = 'default', float $factor = 3.0): ?array
{
try {
$stmt = $this->db->prepare('
SELECT
(SELECT metric_value FROM watchdog_metrics
WHERE source = :s1 AND instance = :i1 AND metric_key = :k1
ORDER BY recorded_utc DESC LIMIT 1) AS current_value,
(SELECT AVG(metric_value) FROM watchdog_metrics
WHERE source = :s2 AND instance = :i2 AND metric_key = :k2
AND recorded_utc BETWEEN (UTC_TIMESTAMP() - INTERVAL 7 DAY)
AND (UTC_TIMESTAMP() - INTERVAL 1 HOUR)) AS baseline_value
');
$stmt->execute([
':s1' => $source, ':i1' => $instance, ':k1' => $metricKey,
':s2' => $source, ':i2' => $instance, ':k2' => $metricKey,
]);
$row = $stmt->fetch();
if (!is_array($row) || $row['current_value'] === null || $row['baseline_value'] === null) {
return null;
}
$current = (float)$row['current_value'];
$baseline = (float)$row['baseline_value'];
if (abs($baseline) < 0.0001) {
return null;
}
$ratio = $current / $baseline;
return [
'deviates' => $ratio >= $factor || $ratio <= (1 / $factor),
'current' => $current,
'baseline' => $baseline,
'factor' => $ratio,
];
} catch (\Throwable $e) {
return null;
}
}
/** Entfernt Werte, die aelter als die Aufbewahrungsfrist sind. */
public function purge(): int
{
try {
$stmt = $this->db->prepare(
'DELETE FROM watchdog_metrics
WHERE recorded_utc < (UTC_TIMESTAMP() - INTERVAL ' . self::RETENTION_DAYS . ' DAY)
LIMIT 50000'
);
$stmt->execute();
return $stmt->rowCount();
} catch (\Throwable $e) {
Logger::warning('Metrik-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
return 0;
}
}
/**
* Verschachtelte Metriken flach klopfen: {"cpu":{"load":1.2}} -> "cpu.load".
*
* @return array<string,float>
*/
private static function flatten(array $metrics, string $prefix = '', int $depth = 0): array
{
if ($depth > 3) {
return [];
}
$out = [];
foreach ($metrics as $key => $value) {
if (!is_string($key) && !is_int($key)) {
continue;
}
$name = $prefix === '' ? (string)$key : $prefix . '.' . $key;
if (is_array($value)) {
$out += self::flatten($value, $name, $depth + 1);
continue;
}
if (is_bool($value)) {
$out[$name] = $value ? 1.0 : 0.0;
continue;
}
if (is_numeric($value)) {
$out[$name] = (float)$value;
}
}
return $out;
}
}
+89 -5
View File
@@ -72,6 +72,10 @@ final class MonitorRepo
/** /**
* Nimmt einen Heartbeat entgegen und legt den Monitor bei Bedarf an. * Nimmt einen Heartbeat entgegen und legt den Monitor bei Bedarf an.
*/ */
/**
* @param mixed $metrics
* @param mixed $checks Gesundheitszustand, den die Anwendung selbst ermittelt hat.
*/
public function upsertHeartbeat( public function upsertHeartbeat(
string $source, string $source,
string $instance, string $instance,
@@ -81,18 +85,35 @@ final class MonitorRepo
string $status, string $status,
?string $message, ?string $message,
?string $groupKey = null, ?string $groupKey = null,
?string $os = null ?string $os = null,
$checks = null
): array { ): array {
$metricsJson = (is_array($metrics) || is_object($metrics)) $metricsJson = (is_array($metrics) || is_object($metrics))
? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null; : null;
// Die Anwendung meldet ihren Gesundheitszustand selbst mit. Das
// Deploymentcenter interpretiert die Namen der Pruefungen nicht - es
// liest nur ok und message. Damit muss auf der Zielmaschine kein Port
// geoeffnet werden, und jede Anwendung entscheidet selbst, was bei ihr
// "gesund" bedeutet.
$failing = self::failingChecks($checks);
$healthJson = (is_array($checks) || is_object($checks))
? json_encode($checks, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$state = match ($status) { $state = match ($status) {
'ok' => 'up', 'ok' => 'up',
'warning' => 'warning', 'warning' => 'warning',
default => 'down', default => 'down',
}; };
// Eine fehlgeschlagene Pruefung stuft einen als "ok" gemeldeten
// Heartbeat herab: der Prozess laeuft, tut aber nicht, was er soll.
if ($failing !== [] && $state === 'up') {
$state = 'warning';
}
$previous = $this->getMonitor($source, $instance); $previous = $this->getMonitor($source, $instance);
$previousState = $previous !== null ? (string)$previous['state'] : null; $previousState = $previous !== null ? (string)$previous['state'] : null;
@@ -102,12 +123,12 @@ final class MonitorRepo
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO watchdog_monitors ( INSERT INTO watchdog_monitors (
source, instance, type, state, last_state_change_utc, expected_interval_sec, source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_seen_utc, last_status, last_message, metrics_json, group_key, os, last_seen_utc, last_status, last_message, metrics_json, health_json,
created_utc, updated_utc failing_checks, group_key, os, created_utc, updated_utc
) VALUES ( ) VALUES (
:source, :instance, :type, :state, UTC_TIMESTAMP(), :interval, :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
UTC_TIMESTAMP(), :last_status, :message, :metrics, :group_key, :os, UTC_TIMESTAMP(), :last_status, :message, :metrics, :health,
UTC_TIMESTAMP(), UTC_TIMESTAMP() :failing, :group_key, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP()
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
-- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
@@ -121,6 +142,8 @@ final class MonitorRepo
last_status = VALUES(last_status), last_status = VALUES(last_status),
last_message = VALUES(last_message), last_message = VALUES(last_message),
metrics_json = VALUES(metrics_json), metrics_json = VALUES(metrics_json),
health_json = COALESCE(VALUES(health_json), health_json),
failing_checks = VALUES(failing_checks),
group_key = COALESCE(VALUES(group_key), group_key), group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os), os = COALESCE(VALUES(os), os),
updated_utc = VALUES(updated_utc) updated_utc = VALUES(updated_utc)
@@ -135,6 +158,8 @@ final class MonitorRepo
':last_status' => in_array($status, ['ok', 'warning', 'error'], true) ? $status : 'error', ':last_status' => in_array($status, ['ok', 'warning', 'error'], true) ? $status : 'error',
':message' => $message, ':message' => $message,
':metrics' => $metricsJson, ':metrics' => $metricsJson,
':health' => $healthJson,
':failing' => $failing !== [] ? mb_substr(implode(', ', $failing), 0, 255) : null,
':group_key' => $groupKey, ':group_key' => $groupKey,
':os' => $os, ':os' => $os,
]); ]);
@@ -146,10 +171,52 @@ final class MonitorRepo
$monitor['_previous_state'] = $previousState; $monitor['_previous_state'] = $previousState;
$monitor['_state_changed'] = $previousState !== null && $previousState !== $state; $monitor['_state_changed'] = $previousState !== null && $previousState !== $state;
$monitor['_failing_checks'] = $failing;
return $monitor; return $monitor;
} }
/**
* Ermittelt die Namen aller fehlgeschlagenen Pruefungen.
*
* Erwartetes Format, das die Anwendung mitschickt:
* { "db": { "ok": true }, "feed": { "ok": false, "message": "..." } }
*
* Akzeptiert zur Bequemlichkeit auch { "db": true, "feed": false }.
*
* @param mixed $checks
* @return list<string>
*/
public static function failingChecks($checks): array
{
if (!is_array($checks) && !is_object($checks)) {
return [];
}
$failing = [];
foreach ((array)$checks as $name => $check) {
if (!is_string($name)) {
continue;
}
if (is_bool($check)) {
if (!$check) {
$failing[] = $name;
}
continue;
}
if (is_array($check) || is_object($check)) {
$data = (array)$check;
if (array_key_exists('ok', $data) && !filter_var($data['ok'], FILTER_VALIDATE_BOOLEAN)) {
$failing[] = $name;
}
}
}
return $failing;
}
/** /**
* Legt einen Monitor manuell an (ohne Heartbeat). * Legt einen Monitor manuell an (ohne Heartbeat).
* *
@@ -357,6 +424,23 @@ final class MonitorRepo
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Vermerkt, dass die Alarmierung dieses Monitors unterdrueckt wird, weil
* ein uebergeordnetes System ausgefallen ist. Der Zustand bleibt sichtbar,
* nur die Benachrichtigung entfaellt.
*/
public function setAlertSuppression(int $id, ?string $blockingParent): bool
{
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET alert_suppressed_by = :parent
WHERE id = :id
');
$stmt->execute([':parent' => $blockingParent, ':id' => $id]);
return $stmt->rowCount() > 0;
}
public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('