feat: Enhance error logging for PDF preview failures and SQL timeouts

- Implemented a new method `ShapeTableNames` to gather entity and navigation table names, ensuring no blanks or duplicates are included.
- Updated `FdsMfrClient` to handle cases where schema queries return no tables, logging appropriate warnings and preventing misleading errors.
- Introduced `PdfPreviewAsync` in `IntranetController` to manage PDF preview rendering, capturing detailed logs for failures and notifying users with readable error messages.
- Added comprehensive unit tests for error logging and table name shaping to ensure robustness and correctness.
- Documented decision to persist user action failures to the `fds__admin_logdebug` table, enhancing support diagnostics.
This commit is contained in:
Stefan
2026-09-16 16:35:04 +02:00
parent 38e0d55b09
commit fce83c528f
19 changed files with 609 additions and 56 deletions
@@ -75,10 +75,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token"));
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
string token = Form("token");
return await PdfPreviewAsync("inv/dpreview", token, async () =>
{
var doc = await _invoiceDrafts.RenderPreviewAsync(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
// POST inv/dsave — { token } → { ok, invid }; flush cache→DB + business event + draftReady
@@ -300,9 +300,10 @@ public partial class IntranetController
}
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
return Form("typ") != "img"
? await FileContentResultAsync(content!, file.MimeType(), file.Name)
: await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) });
if (Form("typ") != "img")
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
}
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
@@ -0,0 +1,88 @@
using System.Diagnostics;
using Fuchs.Observability;
using Microsoft.AspNetCore.Mvc;
namespace Fuchs.Controllers;
// Partial class: shared guard for every "PDF-Vorschau" endpoint (inv/dpreview, rem/dpreview,
// req/idoc+img, rem/idoc+img, inv/rdoc+img, rem/rdoc+img).
//
// A preview render can fail deep inside MigraDoc / Spire (fonts, licence, malformed HTML in an
// item text, an oversized inline image, ...). Before this guard such a failure only surfaced as
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the
// fds__admin_logdebug table support actually reads, and an empty "Der Server hat einen Fehler
// gemeldet:" alert in the browser. Every preview now goes through PdfPreviewAsync so the failure
// is (1) written to the DB log with what/id/user/duration, (2) counted, (3) toasted to the user
// and (4) returned with a readable description header.
public partial class IntranetController
{
internal const string PdfPreviewLogReference = "PDF preview failed";
/// <summary>
/// Runs <paramref name="render"/> (the endpoint body that produces the preview response) and
/// turns any exception into a fully logged, user-readable 500.
/// </summary>
/// <param name="what">Endpoint tag, e.g. <c>inv/dpreview</c>.</param>
/// <param name="refId">Draft token / invoice / reminder / report id the preview is for.</param>
private async Task<IActionResult> PdfPreviewAsync(string what, string refId, Func<Task<IActionResult>> render)
{
var sw = Stopwatch.StartNew();
using var act = FuchsTelemetry.StartActivity("pdf.preview");
act?.SetTag("fuchs.pdf.preview.what", what);
act?.SetTag("fuchs.pdf.preview.ref", refId);
_logger.LogDebug("PDF preview {What} ref={RefId} user={User} started", what, refId, UserAccountID);
try
{
var result = await render();
sw.Stop();
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "success"));
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} done in {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure"));
_logger.LogError(ex, "PDF preview {What} ref={RefId} user={User} failed after {Ms} ms",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
_intranet.debug_log($"{PdfPreviewLogReference} ({what})", ex, UserAccountID,
BuildPdfPreviewLogData(what, refId, UserAccountID, sw.ElapsedMilliseconds));
await _events.UserIssueAsync(
"PDF-Vorschau fehlgeschlagen",
"Die PDF-Vorschau konnte nicht erstellt werden. Der Fehler wurde protokolliert.",
UserAccountID,
new Dictionary<string, object?> { ["what"] = what, ["ref"] = refId });
return ServerError(PdfPreviewFailureMessage(ex));
}
}
/// <summary>Serialisable payload for the DB log row of a failed preview.</summary>
internal static Dictionary<string, object?> BuildPdfPreviewLogData(string what, string refId, string user, long elapsedMs) =>
new()
{
["what"] = what,
["ref"] = refId,
["user"] = user,
["ms"] = elapsedMs
};
/// <summary>
/// User-facing message: the innermost exception text is included so the customer can quote
/// it to support (this is an internal intranet; the message never carries secrets).
/// </summary>
internal static string PdfPreviewFailureMessage(Exception ex)
{
var root = ex;
while (root.InnerException != null) root = root.InnerException;
string detail = (root.Message ?? "").Trim();
return detail.Length == 0
? "Die PDF-Vorschau konnte nicht erstellt werden."
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
}
}
@@ -89,9 +89,11 @@ public partial class IntranetController
_logger.LogWarning("Reminder rdoc: document not found for reminder {RemId} user={User}", Form("id"), UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
return Form("typ") != "img"
? await FileContentResultAsync(fc, file.MimeType(), file.Name)
: await JSONAsync(new { id = Form("id"), img = await BuildPdfImageArray(fc) });
if (Form("typ") != "img")
return await FileContentResultAsync(fc, file.MimeType(), file.Name);
string remId = Form("id");
return await PdfPreviewAsync("rem/rdoc", remId, async () =>
await JSONAsync(new { id = remId, img = await BuildPdfImageArray(fc) }));
}
case "idoc": return await HandleReminderIdoc(fn, id, code);
@@ -211,8 +213,11 @@ public partial class IntranetController
: _pdf.DocToPdfBytes(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await FileContentResultAsync(ct, "application/pdf", filename, inline: true);
}
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
return await PdfPreviewAsync("rem/idoc", fdRem.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
private async Task<IActionResult> HandleReminderResend(string fn, string id, string code)
@@ -67,10 +67,14 @@ public partial class IntranetController
private async Task<IActionResult> HandleReminderDraftPreview(string fn, string id, string code)
{
if (!HasForm("token")) return BadRequest400();
var doc = _reminderDrafts.RenderPreview(Form("token"));
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
string token = Form("token");
return await PdfPreviewAsync("rem/dpreview", token, async () =>
{
var doc = _reminderDrafts.RenderPreview(token);
if (doc == null) return DraftGone();
var imgcol = await _pdf.DocToImageCollectionAsync(doc);
return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
// POST rem/dsave — { token } → { ok, remid }; flush cache→DB + business event + draftReady
@@ -405,8 +405,11 @@ public partial class IntranetController
SetInlinePdfFilename(filename);
return File(ct, "application/pdf");
}
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
return await PdfPreviewAsync("req/idoc", fdInv.Id, async () =>
{
var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft));
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
});
}
private async Task<IActionResult> HandleRequestResend(string fn, string id, string code)
+41 -2
View File
@@ -123,7 +123,25 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
// ── Action helpers ────────────────────────────────────────────────────────
protected IActionResult Unauthorized401() => StatusCode(401);
protected IActionResult BadRequest400() => BadRequest();
protected IActionResult ServerError(string msg = "") => StatusCode(500, new { error = msg });
/// <summary>
/// 500 with the message both in the JSON body and in the <c>x-ocms-desc</c> header, which is
/// the only place the frontend's default failure handler (<c>$ocms.failure</c>) reads a
/// description from — without it the user only ever saw an empty "Der Server hat einen Fehler
/// gemeldet:" alert.
/// </summary>
protected IActionResult ServerError(string msg = "")
{
if (!string.IsNullOrEmpty(msg))
Response.Headers["x-ocms-desc"] = EncodeResponseDescription(msg);
return StatusCode(500, new { error = msg });
}
/// <summary>
/// Header-safe (ASCII, percent-encoded) form of a user message; the client decodes it with
/// <c>decodeURIComponent</c> after mapping <c>+</c> to a space, so spaces must be <c>%20</c>.
/// </summary>
internal static string EncodeResponseDescription(string msg) =>
Uri.EscapeDataString((msg ?? "").Replace("\r", " ").Replace("\n", " ").Trim());
// ── Index (GET /) ─────────────────────────────────────────────────────────
[AllowAnonymous]
@@ -189,6 +207,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
{
_logger.LogError(ex, "Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User}",
fn, id, code, UserAccountID);
// Also persist to fds__admin_logdebug: the ILogger sink is a file on the web server,
// while the DB log is what support actually pulls from the customer's database — an
// unhandled handler exception used to be invisible there.
_intranet.debug_log($"Unhandled exception in Do {fn}/{id}", ex, UserAccountID,
BuildDoErrorLogData(fn, id, code, UserAccountID, RequestFormKeys()));
// Standing rule: an exception that interrupts a user-initiated action must reach the
// user as a notification, not only the log. This is the catch-all safety net for any
// Do_Process_* action that throws without first publishing its own (more specific)
@@ -202,10 +225,26 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
UserAccountID,
new Dictionary<string, object?> { ["fn"] = fn });
}
return ServerError();
return ServerError("Die Aktion konnte aufgrund eines unerwarteten Fehlers nicht abgeschlossen werden.");
}
}
/// <summary>Form field names of the current request (never values — they may carry PII/tokens).</summary>
private string[] RequestFormKeys() =>
Request.HasFormContentType ? Request.Form.Keys.ToArray() : Array.Empty<string>();
/// <summary>Serialisable context for the DB log row of an unhandled <see cref="Do"/> exception.</summary>
internal static Dictionary<string, object?> BuildDoErrorLogData(
string fn, string id, string code, string user, IReadOnlyCollection<string> formKeys) =>
new()
{
["fn"] = fn,
["id"] = id,
["code"] = code,
["user"] = user,
["formKeys"] = formKeys
};
// ── Auth helper ───────────────────────────────────────────────────────────
private async Task<IActionResult> HandleAuth(string fn, string id, string code)
{