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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user