fix(notifications): redact failure details

This commit is contained in:
Sucukdeluxe
2026-08-22 08:17:50 +02:00
parent 1b8f6fe4b6
commit 259fa7094b
5 changed files with 226 additions and 14 deletions
+3 -1
View File
@@ -10,6 +10,7 @@ import type {
NotificationEventType,
NotificationPriority
} from "./notification-outbox";
import { projectPackageFailureCategory } from "./package-telemetry";
export interface PackageResultEnvelope {
generation: number;
@@ -202,9 +203,10 @@ export function buildPackageNotificationEvent(
{ name: "Archive", value: `${result.archiveCount} Gruppen · ${result.partCount} Parts · ${result.outputCount} Ausgaben`, inline: true }
];
if (result.failurePhase) {
const errorCategory = projectPackageFailureCategory(result.failurePhase, result.errorCategory);
fields.push({
name: "Fehler",
value: `${failurePhaseLabel(result)}${result.errorCategory ? ` · ${result.errorCategory.slice(0, 256)}` : ""}`,
value: `${failurePhaseLabel(result)} · ${errorCategory}`,
inline: false
});
}
+25 -1
View File
@@ -2,6 +2,8 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import type { DiscordEmbedFieldPayload } from "./notify";
import { projectPackageFailureCategory } from "./package-telemetry";
import type { FailurePhase } from "../shared/types";
export type NotificationEventType =
| "package_completed"
@@ -66,12 +68,31 @@ const EVENT_TYPES = new Set<NotificationEventType>([
const MAX_EVENTS = 250;
const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
"package_partial",
"package_failed"
]);
const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
["Download", "download"],
["Entpacken", "extract"],
["Remux", "remux"],
["Aufräumen", "cleanup"]
]);
function finiteInteger(value: unknown, fallback = 0): number {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
}
function sanitizePackageFailureFieldValue(value: string): string {
const match = /^(Download|Entpacken|Remux|Aufräumen)(?:\s*·\s*(.*))?$/s.exec(value.trim());
if (!match) {
return "Unbekannt";
}
const phase = PACKAGE_FAILURE_PHASES.get(match[1]) ?? null;
return `${match[1]} · ${projectPackageFailureCategory(phase, match[2])}`;
}
function sanitizeEvent(value: unknown): NotificationEvent | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -93,7 +114,10 @@ function sanitizeEvent(value: unknown): NotificationEvent | null {
return [];
}
const name = typeof field.name === "string" ? field.name.slice(0, 1024) : "";
const fieldValue = typeof field.value === "string" ? field.value.slice(0, 4096) : "";
const rawFieldValue = typeof field.value === "string" ? field.value.slice(0, 4096) : "";
const fieldValue = name === "Fehler" && PACKAGE_FAILURE_EVENT_TYPES.has(type)
? sanitizePackageFailureFieldValue(rawFieldValue)
: rawFieldValue;
return name && fieldValue ? [{ name, value: fieldValue, inline: Boolean(field.inline) }] : [];
})
: [];
+57 -4
View File
@@ -7,6 +7,31 @@ import type {
RemuxOperationMetric
} from "../shared/types";
export type PackageFailureCategory =
| "Netzwerk"
| "Timeout"
| "Offline"
| "Speicherplatz"
| "Berechtigung"
| "Download"
| "Entpacken"
| "Remux"
| "Cleanup"
| "Unbekannt";
const packageFailureCategories = new Map<string, PackageFailureCategory>([
["netzwerk", "Netzwerk"],
["timeout", "Timeout"],
["offline", "Offline"],
["speicherplatz", "Speicherplatz"],
["berechtigung", "Berechtigung"],
["download", "Download"],
["entpacken", "Entpacken"],
["remux", "Remux"],
["cleanup", "Cleanup"],
["unbekannt", "Unbekannt"]
]);
function finiteNonNegative(value: unknown): number {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
@@ -26,6 +51,34 @@ export function sumOperationDurationSeconds(operations: readonly { durationMs: n
return durationMsToSeconds(operations.reduce((total, operation) => total + finiteNonNegative(operation.durationMs), 0));
}
export function projectPackageFailureCategory(failurePhase: FailurePhase, detail: unknown): PackageFailureCategory {
const normalized = String(detail ?? "").trim().slice(0, 2048).toLowerCase();
const knownCategory = packageFailureCategories.get(normalized);
if (knownCategory) {
return knownCategory;
}
if (/\b(?:e?timed?[\s_-]*out|timeout)\b|zeit(?:ü|ue)berschreitung/.test(normalized)) {
return "Timeout";
}
if (/\boffline\b|not[\s_-]*found|nicht[\s_-]*gefunden|dead[\s_-]*link|http\s*404/.test(normalized)) {
return "Offline";
}
if (/\benospc\b|disk[\s_-]*full|no[\s_-]+space[\s_-]+left|not[\s_-]+enough[\s_-]+(?:disk[\s_-]+)?space|insufficient[\s_-]+(?:disk[\s_-]+)?space|speicherplatz|datenträger[^\n]*voll/.test(normalized)) {
return "Speicherplatz";
}
if (/\beacces\b|\beperm\b|permission|access[\s_-]*denied|zugriff[^\n]*verweigert|berechtigung/.test(normalized)) {
return "Berechtigung";
}
if (/network|netzwerk|\beconn|\benet|\behost|\beai_again\b|\bdns\b|socket|connection|verbindung|fetch[\s_-]*failed/.test(normalized)) {
return "Netzwerk";
}
if (failurePhase === "download") return "Download";
if (failurePhase === "extract") return "Entpacken";
if (failurePhase === "remux") return "Remux";
if (failurePhase === "cleanup") return "Cleanup";
return "Unbekannt";
}
function classifyStatus(successfulFiles: number, failedFiles: number, cancelledFiles: number, packageCancelled: boolean): PackageResultStatus {
if (successfulFiles > 0 && (failedFiles > 0 || cancelledFiles > 0 || packageCancelled)) {
return "partial";
@@ -47,19 +100,19 @@ function getFailure(
downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) {
return { failurePhase: "cleanup", errorCategory: cleanupErrorCategory };
return { failurePhase: "cleanup", errorCategory: projectPackageFailureCategory("cleanup", cleanupErrorCategory) };
}
const failedRemux = remuxOperations.find((operation) => operation.status === "failed");
if (failedRemux || remuxFallbackFailures > 0) {
return { failurePhase: "remux", errorCategory: failedRemux?.errorCategory || "remux" };
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
}
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive) {
return { failurePhase: "extract", errorCategory: failedArchive.errorCategory };
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) };
}
const downloadError = downloadErrors.find(Boolean);
if (downloadErrors.length > 0) {
return { failurePhase: "download", errorCategory: downloadError || "download" };
return { failurePhase: "download", errorCategory: projectPackageFailureCategory("download", downloadError) };
}
return { failurePhase: null, errorCategory: "" };
}