From 24e66208bc417a0b82bd033b48b6337a45e458ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Brzezina?= Date: Tue, 13 Oct 2026 15:06:57 +0200 Subject: [PATCH] chore(provider-arr): address #57 review follow-ups --- .../file-ingestion/file-discovery.service.ts | 3 ++- .../file-ingestion/log-file-processor.spec.ts | 6 ++--- packages/provider-arr/src/arr.parser.spec.ts | 26 +++++++++++++++++++ packages/provider-arr/src/arr.parser.ts | 20 +++++++------- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/backend/src/modules/file-ingestion/file-discovery.service.ts b/apps/backend/src/modules/file-ingestion/file-discovery.service.ts index 5669ec7..cedf346 100644 --- a/apps/backend/src/modules/file-ingestion/file-discovery.service.ts +++ b/apps/backend/src/modules/file-ingestion/file-discovery.service.ts @@ -137,7 +137,8 @@ export class FileDiscoveryService { /** * A file is included when it matches an include pattern and no exclude pattern. - * Patterns are matched against the base name (a direct file path is reduced to it). + * Exclude patterns are matched against the base name; include patterns against `filename` + * as given (directory scans pass the base name, direct file paths pass the full path). */ private isIncluded(filename: string, patterns: string[], excludePatterns: string[]): boolean { const baseName = filename.split(/[/\\]/).pop() ?? filename; diff --git a/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts b/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts index 89ece45..cb87829 100644 --- a/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts +++ b/apps/backend/src/modules/file-ingestion/log-file-processor.spec.ts @@ -81,9 +81,9 @@ describe('LogFileProcessor with *arr multi-line errors', () => { expect(entries[0]?.stackTrace).toBeUndefined(); }); - it('does not attach or emit an unparseable non-continuation line between entries', () => { - // 8-digit fraction does not match the NLog format (known limitation) so it is a continuation - // of the pending entry; the surrounding entries must still be emitted intact. + it('keeps an unparseable line (8-digit fraction) as a continuation of the pending entry, not as its own entry', () => { + // An 8-digit fraction does not match the NLog format (known limitation), so the line is + // folded into the pending entry's stack trace; the surrounding entries are emitted intact. const entries = feed([ '2026-10-10 12:00:05.4|Info|A|one', '2026-10-10 12:00:05.12345678|Info|B|weird', diff --git a/packages/provider-arr/src/arr.parser.spec.ts b/packages/provider-arr/src/arr.parser.spec.ts index 203a313..5b91517 100644 --- a/packages/provider-arr/src/arr.parser.spec.ts +++ b/packages/provider-arr/src/arr.parser.spec.ts @@ -282,6 +282,32 @@ describe('Arr Parser', () => { expect(result.message).toBe('bad state happened'); }); + it.each([ + 'System.Exception', + 'Npgsql.PostgresException', + 'SQLite.SQLiteException', + 'Polly.Timeout.TimeoutRejectedException', + 'FluentValidation.ValidationException', + ])('recognises %s outside the old allowlist', (type) => { + const result = finalizeArrLogEntry(entry, [ + `[v4.0.10.2544] ${type}: it broke`, + ' at Foo()', + ]); + + expect(result.exception).toBe(type); + expect(result.message).toBe('Boom - it broke'); + expect(isArrLogContinuation(`${type}: it broke`)).toBe(true); + }); + + it.each([ + 'Exception: it broke', + 'Request failed. Exception: it broke', + 'Something.Exceptional: it broke', + 'Some.Type Exception: it broke', + ])('does not treat %j as an exception line', (line) => { + expect(finalizeArrLogEntry(entry, [line])).toBe(entry); + }); + it('returns the entry unchanged when there is no exception line', () => { expect(finalizeArrLogEntry(entry, [' at Foo.Bar()'])).toBe(entry); }); diff --git a/packages/provider-arr/src/arr.parser.ts b/packages/provider-arr/src/arr.parser.ts index b744a51..8799ab5 100644 --- a/packages/provider-arr/src/arr.parser.ts +++ b/packages/provider-arr/src/arr.parser.ts @@ -242,6 +242,13 @@ export function parseArrLogLine(line: string): ParsedLogEntry | null { }; } +/** + * A .NET exception line: a namespace-qualified type name ending in `Exception`, then `:`. + * Requires at least one dot and no whitespace in the type, so it matches `System.Exception`, + * `Npgsql.PostgresException` or `Polly.Timeout.TimeoutRejectedException` but not ordinary prose. + */ +const EXCEPTION_LINE_REGEX = /^((?:\w+\.)+\w*Exception):\s*(.*)$/; + /** * Check if a line is a continuation of a previous log entry * @@ -259,7 +266,7 @@ export function isArrLogContinuation(line: string): boolean { if (/^\s{2,}at\s+/.test(line)) return true; // .NET exception types - if (/^(System|Microsoft|NLog|NzbDrone)\.\w+(\.\w+)*Exception:/i.test(trimmed)) return true; + if (EXCEPTION_LINE_REGEX.test(trimmed)) return true; // Inner exception marker if (/^--->\s+/.test(trimmed)) return true; @@ -279,13 +286,8 @@ export function isArrLogContinuation(line: string): boolean { } /** - * Parse a log line with context for multi-line entry support. - * - * Follows the `MediaServerProvider.parseLogLineWithContext` contract: `entry` is the - * entry parsed from THIS line (not the previous one), continuation lines are flagged with - * `isContinuation` so the caller can buffer them, and `previousComplete` tells the caller - * the previously pending entry is finished. Attaching continuation lines is the caller's - * job; use `finalizeArrLogEntry` to enrich the assembled entry with exception details. + * Parse a log line with context for multi-line entry support (`entry` is the entry for + * THIS line). Use `finalizeArrLogEntry` to enrich the assembled entry with exception details. */ export function parseArrLogLineWithContext(line: string, context: LogParseContext): LogParseResult { // Blank lines carry no information. NLog emits one between the header and the @@ -322,7 +324,7 @@ export function finalizeArrLogEntry( const match = first ?.trim() .replace(/^\[v[\d.]+\]\s*/, '') - .match(/^((?:System|Microsoft|NLog|NzbDrone)\.\w+(?:\.\w+)*Exception):\s*(.*)$/i); + .match(EXCEPTION_LINE_REGEX); if (!match) return entry; const [, exception, exceptionMessage] = match;