diff --git a/src/main/notification-events.ts b/src/main/notification-events.ts index fb32c00..44b448d 100644 --- a/src/main/notification-events.ts +++ b/src/main/notification-events.ts @@ -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 }); } diff --git a/src/main/notification-outbox.ts b/src/main/notification-outbox.ts index aaff963..ee1594d 100644 --- a/src/main/notification-outbox.ts +++ b/src/main/notification-outbox.ts @@ -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([ 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([ + "package_partial", + "package_failed" +]); +const PACKAGE_FAILURE_PHASES = new Map([ + ["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) }] : []; }) : []; diff --git a/src/main/package-telemetry.ts b/src/main/package-telemetry.ts index b970207..d1ec9e9 100644 --- a/src/main/package-telemetry.ts +++ b/src/main/package-telemetry.ts @@ -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([ + ["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: "" }; } diff --git a/tests/notification-outbox.test.ts b/tests/notification-outbox.test.ts index 74716e7..3ee4afe 100644 --- a/tests/notification-outbox.test.ts +++ b/tests/notification-outbox.test.ts @@ -4,7 +4,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox"; -import { sendNotification } from "../src/main/notify"; +import { buildPackageNotificationEvent } from "../src/main/notification-events"; +import { buildNotifyRequest, sendNotification } from "../src/main/notify"; +import type { PackageResult } from "../src/shared/types"; const tempDirs: string[] = []; @@ -277,6 +279,93 @@ describe("NotificationOutbox", () => { expect(persisted(filePath).events[0]).toEqual(event("safe")); }); + it("keeps private package failure details out of events, requests, and persisted state", async () => { + const filePath = createOutboxFile(); + const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; + const result: PackageResult = { + packageId: "pkg-private", + name: "Paket", + status: "failed", + startedAt: 1000, + downloadEndedAt: 2000, + postProcessStartedAt: 0, + completedAt: 2000, + downloadDurationSeconds: 1, + extractionDurationSeconds: 0, + remuxDurationSeconds: 0, + postProcessDurationSeconds: 0, + totalDurationSeconds: 1, + totalBytes: 1000, + downloadedBytes: 0, + averageDownloadSpeedBps: 0, + successfulFiles: 0, + failedFiles: 1, + cancelledFiles: 0, + archiveCount: 0, + partCount: 0, + outputCount: 0, + failurePhase: "download", + errorCategory: privateDetails, + archiveOperations: [], + remuxOperations: [] + }; + const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 1000); + const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { + title: notificationEvent.payload.title, + message: notificationEvent.payload.description || "", + color: notificationEvent.payload.color, + fields: notificationEvent.payload.fields, + timestamp: notificationEvent.createdAt + }); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + + await outbox.enqueue(notificationEvent); + + const eventText = JSON.stringify(notificationEvent); + const requestText = String(request.init.body); + const persistedText = fs.readFileSync(filePath, "utf8"); + const sensitiveValues = [ + "https://private.example.test/hook", + "C:/Private/target", + "alice@example.test", + "token=SUPERSECRET" + ]; + expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe("Download · Download"); + for (const sensitiveValue of sensitiveValues) { + expect(eventText).not.toContain(sensitiveValue); + expect(requestText).not.toContain(sensitiveValue); + expect(persistedText).not.toContain(sensitiveValue); + } + }); + + it("projects private failure details from an existing outbox before persisting again", async () => { + const filePath = createOutboxFile(); + const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; + const legacyEvent = event("legacy-private", { + payload: { + title: "Paket fehlgeschlagen", + description: "Paket", + fields: [{ name: "Fehler", value: `Download · ${privateDetails}`, inline: false }] + } + }); + fs.writeFileSync(filePath, JSON.stringify({ + version: 1, + events: [legacyEvent], + lastSuccessAt: 0, + lastFailureAt: 0 + }), "utf8"); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + + await outbox.enqueue(event("safe")); + + const persistedText = fs.readFileSync(filePath, "utf8"); + expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Download · Download"); + expect(persistedText).not.toContain("https://private.example.test/hook"); + expect(persistedText).not.toContain("C:/Private/target"); + expect(persistedText).not.toContain("alice@example.test"); + expect(persistedText).not.toContain("token=SUPERSECRET"); + }); + it("returns after the default three-second shutdown budget when sending hangs", async () => { vi.useFakeTimers(); const filePath = createOutboxFile(); diff --git a/tests/package-telemetry.test.ts b/tests/package-telemetry.test.ts index 7b817e5..1fb457f 100644 --- a/tests/package-telemetry.test.ts +++ b/tests/package-telemetry.test.ts @@ -153,7 +153,7 @@ describe("package lifecycle telemetry", () => { partCount: 16, archiveCount: 1, failurePhase: "extract", - errorCategory: "checksum" + errorCategory: "Entpacken" })); }); @@ -170,7 +170,7 @@ describe("package lifecycle telemetry", () => { failedFiles: 1, cancelledFiles: 0, failurePhase: "download", - errorCategory: "download-error" + errorCategory: "Download" })); }); @@ -185,10 +185,46 @@ describe("package lifecycle telemetry", () => { status: "failed", failedFiles: 1, failurePhase: "download", - errorCategory: "download" + errorCategory: "Download" })); }); + it("projects private download failure details to a fixed category", () => { + const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; + const item = { + ...downloadItem("item-1", "failed"), + lastError: privateDetails, + fullStatus: privateDetails + }; + const result = finalizePackageResult(telemetry({ + package: packageEntry({ status: "failed", itemIds: [item.id] }), + items: [item] + })); + + expect(result.errorCategory).toBe("Download"); + expect(result.errorCategory).not.toContain("private.example.test"); + expect(result.errorCategory).not.toContain("C:/Private/target"); + expect(result.errorCategory).not.toContain("alice@example.test"); + expect(result.errorCategory).not.toContain("SUPERSECRET"); + }); + + it.each([ + ["Host ist offline", "Offline"], + ["Request ETIMEDOUT", "Timeout"], + ["socket ECONNRESET", "Netzwerk"], + ["ENOSPC: no space left on device", "Speicherplatz"], + ["EACCES: permission denied", "Berechtigung"], + ["nicht näher klassifizierbar", "Download"] + ])("maps download failure detail %s to %s", (detail, expectedCategory) => { + const item = { ...downloadItem("item-1", "failed"), lastError: detail }; + const result = finalizePackageResult(telemetry({ + package: packageEntry({ status: "failed", itemIds: [item.id] }), + items: [item] + })); + + expect(result.errorCategory).toBe(expectedCategory); + }); + it("classifies a package with only cancelled work as cancelled", () => { const item = downloadItem("item-1", "cancelled"); const result = finalizePackageResult(telemetry({ @@ -268,11 +304,19 @@ describe("package lifecycle telemetry", () => { const failedItem = downloadItem("item-1", "failed"); const failedArchive = archiveOperation({ status: "failed", errorCategory: "archive-error" }); const failedRemux = remuxOperation({ status: "failed", errorCategory: "remux-error" }); + const failures = [ + finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })), + finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })), + finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })), + finalizePackageResult(telemetry({ items: [failedItem] })) + ]; - expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })).failurePhase).toBe("cleanup"); - expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })).failurePhase).toBe("remux"); - expect(finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })).failurePhase).toBe("extract"); - expect(finalizePackageResult(telemetry({ items: [failedItem] })).failurePhase).toBe("download"); + expect(failures.map(({ failurePhase, errorCategory }) => ({ failurePhase, errorCategory }))).toEqual([ + { failurePhase: "cleanup", errorCategory: "Cleanup" }, + { failurePhase: "remux", errorCategory: "Remux" }, + { failurePhase: "extract", errorCategory: "Entpacken" }, + { failurePhase: "download", errorCategory: "Download" } + ]); }); it("uses audio-strip outcomes when no individual remux operation was recorded", () => {