feat: migrate DB error logging onto the generic OCORE MultiLogger SQL sink
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:
Stefan
2026-09-16 17:20:06 +02:00
co-authored by Claude Sonnet 5
parent a1e6002c3b
commit bcf1f2dbb6
23 changed files with 757 additions and 225 deletions
+10 -10
View File
@@ -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}";
+5 -19
View File
@@ -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)
{