feat: migrate DB error logging onto the generic OCORE MultiLogger SQL sink
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
Retires Fuchs_intranet.debug_log() and every direct EXECUTE [dbo].[fds__admin_logdebug] call in favor of a single path: ILogger. A new SQL log sink (OCORE.MultiLogger.SqlLoggerProvider, gated by Fuchs:Logging:SqlSink:Enabled, default on) mirrors every _logger.LogWarning/-Error/ -Critical call into [dbo].[fds__debuglog] via a new fds__admin_logdebug_ext proc, at Warning+. - Program.cs registers the sink and RequestLogScopeMiddleware (request id/path/authenticated user, pushed after UseAuthentication) so every log line in a request — including ones written deep inside OCORE's SQL helpers — is attributed automatically. - FIS_SQLOptions, IntranetController.Do's catch-all, PdfPreviewAsync, and the banking-upload handlers now log once, with an explicit EventId preserving the exact CodeReference text the retired debug_log() calls used to write, so existing dashboards/queries over fds__debuglog keep working unchanged. - Fuchs_DataService/FdsMfr.cs's FdsSqlOptions had the identical double-write hazard (it's hosted in the same process per ADR 0010) — the original 2026-09-16 log review's mfr__getSchema/ fds__getUpdateableRequests deadlock rows came from exactly this class. Fixed the same way. - fds__debuglog gains LogLevel/Machine/Category/Account/AuthUser/IsDebug columns; the legacy fds__admin_logdebug proc is left in place unused, not dropped. - FdsDebug.DebugLog (Fuchs_DataService's other ~9 call sites) deliberately left on its direct DB-write path — no ILogger call sits alongside it there, so no duplication risk today. See ADR 0024 (supersedes 0023) and Docs/Concepts/error-logging-pipeline.md. New tests: ErrorLoggingTests (FIS_SQLOptions), FdsSqlOptionsTests, RequestLogScopeMiddlewareTests. 694/694 pass (the 4 PdfPipelineTests failures seen under full-suite parallel runs are a pre-existing, unrelated PdfSharp global-font-resolver test-isolation flake — they pass both individually and in most full-suite runs). Bumps the OCORE submodule to 3137b56 (SQL sink hardening: redaction matches short fragments as whole identifier segments, per-entry-tolerant serialization, lazy dataSerialized, opt-in SqlLoggerOptions.IncludeCategory, LogEntry.Category/AuthUser capture). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a1e6002c3b
commit
bcf1f2dbb6
@@ -55,22 +55,22 @@ public partial class IntranetController
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
// EventId.Name keeps the exact CodeReference the DB log used before the
|
||||
// generic SQL sink existed (ADR 0024); {Data} carries uid/tmptbl into it.
|
||||
_logger.LogError(new EventId(0, "IntranetController.bam.up - sql exception"), exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User} data={Data}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID,
|
||||
new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
_logger.LogError(new EventId(0, "IntranetController.bam.up - command-after exception"), exc,
|
||||
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
|
||||
"rescueTable={RescueTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
"rescueTable={RescueTable} user={User} data={Data}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID,
|
||||
new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
|
||||
@@ -9,11 +9,12 @@ namespace Fuchs.Controllers;
|
||||
//
|
||||
// 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.
|
||||
// the generic catch-all in Do(): an ErrorLog.txt line on the web server, nothing in the DB log
|
||||
// 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) logged with
|
||||
// what/ref/user/duration — which the SQL log sink (ADR 0024) additionally persists to
|
||||
// fds__debuglog — (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";
|
||||
@@ -35,11 +36,15 @@ public partial class IntranetController
|
||||
{
|
||||
var result = await render();
|
||||
sw.Stop();
|
||||
// A draft session that expired between page load and preview click is an expected
|
||||
// client-driven outcome (the browser reopens the draft), not a render failure — keep
|
||||
// it out of the failure telemetry/logging this guard exists to produce.
|
||||
var outcome = result is ObjectResult { StatusCode: 410 } ? "gone" : "success";
|
||||
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);
|
||||
new KeyValuePair<string, object?>("outcome", outcome));
|
||||
_logger.LogInformation("PDF preview {What} ref={RefId} user={User} {Outcome} in {Ms} ms",
|
||||
what, refId, UserAccountID, outcome, sw.ElapsedMilliseconds);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -49,38 +54,34 @@ public partial class IntranetController
|
||||
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",
|
||||
// EventId.Name keeps the exact "PDF preview failed (what)" CodeReference the DB log
|
||||
// used before the generic SQL sink existed (ADR 0024).
|
||||
_logger.LogError(new EventId(0, $"{PdfPreviewLogReference} ({what})"), 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));
|
||||
// Pre-auth flows (some preview endpoints are in _allowedGet, see IntranetController.cs)
|
||||
// have no user session to notify, matching the guard the Do() catch-all uses.
|
||||
if (UserIdent.IsAuthenticated)
|
||||
{
|
||||
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, UserIdent.IsAuthenticated));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// User-facing message. The innermost exception text is only included for an authenticated
|
||||
/// intranet user — some preview endpoints are reachable unauthenticated (see _allowedGet in
|
||||
/// IntranetController.cs), so an anonymous caller gets a generic message instead.
|
||||
/// </summary>
|
||||
internal static string PdfPreviewFailureMessage(Exception ex)
|
||||
internal static string PdfPreviewFailureMessage(Exception ex, bool includeDetail)
|
||||
{
|
||||
var root = ex;
|
||||
while (root.InnerException != null) root = root.InnerException;
|
||||
string detail = (root.Message ?? "").Trim();
|
||||
if (!includeDetail) return "Die PDF-Vorschau konnte nicht erstellt werden.";
|
||||
string detail = ex.GetBaseException().Message?.Trim() ?? "";
|
||||
return detail.Length == 0
|
||||
? "Die PDF-Vorschau konnte nicht erstellt werden."
|
||||
: $"Die PDF-Vorschau konnte nicht erstellt werden: {detail}";
|
||||
|
||||
@@ -205,13 +205,11 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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()));
|
||||
// EventId.Name keeps the exact CodeReference the DB log used before the generic SQL
|
||||
// sink existed (ADR 0024); formKeys is form field NAMES only, never values (PII/tokens).
|
||||
_logger.LogError(new EventId(0, $"Unhandled exception in Do {fn}/{id}"), ex,
|
||||
"Unhandled exception in Do fn={Fn} id={Id} code={Code} user={User} formKeys={FormKeys}",
|
||||
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)
|
||||
@@ -233,18 +231,6 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
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