diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs index e8afef16..77ff880b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs @@ -14,11 +14,15 @@ public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoun /// Edges between steps. /// True when the graph came from static analysis rather than real events. /// True when the inline event list was capped (the audit rows remain complete — Replay shows everything). +/// True when the inline trace collector dropped low-priority log rows to keep room for the step rows — the audit rows remain complete, so Replay shows every line (design 2026-09-09 D7). +/// Count of log lines the builder could not place on any step (design 2026-09-09 D2). public sealed record FlowGraph( IReadOnlyList Steps, IReadOnlyList Edges, bool Predicted, - bool Truncated) + bool Truncated, + bool LogsTruncated = false, + int UnattachedLogLines = 0) { /// An empty, non-predicted graph. public static FlowGraph Empty { get; } = new(Array.Empty(), Array.Empty(), false, false); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs index 247c2cf4..c54f56c4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs @@ -74,6 +74,32 @@ public sealed record FlowLifecycleRow( string? ErrorMessage, int? DurationMs); +/// +/// One log line folded onto a step (; design 2026-09-09 D1/D8) — the +/// projection of a ScriptTrace.ScriptLog audit row, which is never drawn as a node of its own. +/// +/// The audit row's id. +/// Execution the line was written in. +/// When the line was written. +/// debug | info | warn | error. +/// script (a Log.* call) | runtime (the context's own logger while traced). +/// The logged message. +/// Message of an attached exception, when the line carried one. +/// Full ToString() of that exception. +/// Step the line belongs to; null = the execution's anchor step (design D2). +/// Per-run emission counter, breaking same-millisecond ties in display order. +public sealed record FlowLogLine( + Guid EventId, + Guid? ExecutionId, + DateTimeOffset OccurredAtUtc, + string Level, + string Source, + string Message, + string? ExceptionMessage, + string? ExceptionDetail, + Guid? StepEventId, + int? Seq); + /// /// One node of the flow graph — a central-side projection of a ScriptTrace / /// ApiInbound.InboundRequest , or of one outbound @@ -106,4 +132,6 @@ public sealed record FlowStep( string? Target, ScriptTraceExtra? Extra, IReadOnlyList? Lifecycle = null, - Guid? CorrelationId = null); + Guid? CorrelationId = null, + string? ErrorDetail = null, + IReadOnlyList? Logs = null); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs index b9598d29..6e07c2b8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs @@ -43,7 +43,36 @@ public static class FlowStepProjector Instance: d.SourceInstanceId, Target: evt.Target, Extra: extra, - CorrelationId: evt.CorrelationId); + CorrelationId: evt.CorrelationId, + ErrorDetail: d.ErrorDetail); + } + + /// True for a ScriptTrace.ScriptLog row — a log line, never a step. + public static bool IsLogLine(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + var d = AuditDetailsCodec.Deserialize(evt.DetailsJson); + return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal) + && string.Equals(d.Kind, nameof(AuditKind.ScriptLog), StringComparison.Ordinal); + } + + /// Projects a ScriptLog row onto its line. Level/source default to info/script when the extra is missing. + public static FlowLogLine ToLogLine(AuditEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + var d = AuditDetailsCodec.Deserialize(evt.DetailsJson); + var x = ScriptTraceExtra.TryParse(d.Extra); + return new FlowLogLine( + EventId: evt.EventId, + ExecutionId: d.ExecutionId, + OccurredAtUtc: evt.OccurredAtUtc, + Level: x?.LogLevel ?? "info", + Source: x?.LogSource ?? "script", + Message: d.RequestSummary ?? string.Empty, + ExceptionMessage: d.ErrorMessage, + ExceptionDetail: d.ErrorDetail, + StepEventId: x?.StepEventId, + Seq: x?.Seq); } /// Projects one outbound audit row onto its lifecycle entry (). diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs index 09045637..f4c9c6fc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs @@ -49,6 +49,18 @@ public sealed record ScriptTraceExtra /// flow-debugger on rows the debugger synthesizes (the Live-run inbound row, the value-change root). public string? Source { get; init; } + /// On a ScriptLog row: debug | info | warn | error. + public string? LogLevel { get; init; } + + /// On a ScriptLog row: script (a Log.* call) | runtime (the context's own logger while traced). + public string? LogSource { get; init; } + + /// On a ScriptLog row: the pre-minted EventId of the step the line belongs to; null = the execution's anchor step (design D2). + public Guid? StepEventId { get; init; } + + /// On a ScriptLog row: per-run emission counter, breaks same-millisecond ties. + public int? Seq { get; init; } + /// Serializer options shared by every emitter and reader: camelCase, nulls omitted. public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web) { diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs index 4e86bacc..ca706630 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs @@ -61,5 +61,8 @@ public enum AuditKind AttributeWrite, /// One WaitAsync/WaitForAsync outcome in a traced script (traced executions only; ). - AttributeWait + AttributeWait, + + /// One log line a traced script (or the runtime on its behalf) wrote; folded into its owning step by the Flow Debugger, never a node (design 2026-09-09 D1). + ScriptLog } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs new file mode 100644 index 00000000..ff501123 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs @@ -0,0 +1,137 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging; + +/// +/// Flow Debugger step-logs plan (2026-09-09) Task 1: the Commons log-line vocabulary — +/// exception detail on a step (D3), and the ScriptTrace.ScriptLog row projecting +/// onto a rather than a node (D1). +/// +public class FlowStepProjectorLogLineTests +{ + [Fact] + public void Project_CarriesErrorDetail() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed, + target: "S", executionId: Guid.NewGuid(), errorMessage: "boom", errorDetail: "System.InvalidOperationException: boom\n at X"); + + var step = FlowStepProjector.Project(evt); + + Assert.Equal("boom", step.ErrorMessage); + Assert.StartsWith("System.InvalidOperationException", step.ErrorDetail); + } + + [Fact] + public void Project_ErrorDetail_IsNullWhenAbsent() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S"); + + Assert.Null(FlowStepProjector.Project(evt).ErrorDetail); + } + + [Fact] + public void ScriptLog_ProjectsToUnknownKind_NeverItsOwnStepKind() + { + // Mixed-version rule (design §4): an old central renders a ScriptLog row as an Unknown node + // rather than dropping it. No FlowStepKind is added for it. + Assert.Equal(FlowStepKind.Unknown, FlowStepProjector.ResolveKind(nameof(AuditChannel.ScriptTrace), nameof(AuditKind.ScriptLog))); + Assert.False(Enum.GetNames().Contains("ScriptLog")); + } + + [Fact] + public void IsLogLine_TrueOnlyForScriptTraceScriptLog() + { + var log = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, target: "S"); + var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S"); + + Assert.True(FlowStepProjector.IsLogLine(log)); + Assert.False(FlowStepProjector.IsLogLine(run)); + } + + [Fact] + public void IsLogLine_FalseForOtherChannels() + { + var outbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, target: "S"); + + Assert.False(FlowStepProjector.IsLogLine(outbound)); + } + + [Fact] + public void ToLogLine_ReadsLevelSourceAndException() + { + var exec = Guid.NewGuid(); + var extra = new ScriptTraceExtra { LogLevel = "warn", LogSource = "runtime", StepEventId = Guid.NewGuid(), Seq = 3 }; + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, + target: "S", executionId: exec, requestSummary: "acked Uncertain", errorMessage: "inner", + errorDetail: "System.Exception: inner", extra: extra.ToJson()); + + var line = FlowStepProjector.ToLogLine(evt); + + Assert.Equal(evt.EventId, line.EventId); + Assert.Equal(exec, line.ExecutionId); + Assert.Equal(evt.OccurredAtUtc, line.OccurredAtUtc); + Assert.Equal("warn", line.Level); + Assert.Equal("runtime", line.Source); + Assert.Equal("acked Uncertain", line.Message); + Assert.Equal("inner", line.ExceptionMessage); + Assert.Equal("System.Exception: inner", line.ExceptionDetail); + Assert.Equal(extra.StepEventId, line.StepEventId); + Assert.Equal(3, line.Seq); + } + + [Fact] + public void ToLogLine_DefaultsLevelAndSourceWhenExtraMissing() + { + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, target: "S"); + + var line = FlowStepProjector.ToLogLine(evt); + + Assert.Equal("info", line.Level); + Assert.Equal("script", line.Source); + Assert.Equal(string.Empty, line.Message); + Assert.Null(line.ExceptionMessage); + Assert.Null(line.ExceptionDetail); + Assert.Null(line.StepEventId); + Assert.Null(line.Seq); + } + + [Fact] + public void ScriptTraceExtra_LogMembers_RoundTripThroughJson() + { + var extra = new ScriptTraceExtra { LogLevel = "error", LogSource = "script", StepEventId = Guid.NewGuid(), Seq = 12 }; + + var parsed = ScriptTraceExtra.TryParse(extra.ToJson()); + + Assert.NotNull(parsed); + Assert.Equal("error", parsed!.LogLevel); + Assert.Equal("script", parsed.LogSource); + Assert.Equal(extra.StepEventId, parsed.StepEventId); + Assert.Equal(12, parsed.Seq); + } + + [Fact] + public void FlowStep_LogsAndErrorDetail_DefaultToNull() + { + var step = new FlowStep(Guid.NewGuid(), null, null, FlowStepKind.ScriptRun, FlowLane.Site, "S", null, + DateTimeOffset.UnixEpoch, null, FlowStepOutcome.Ok, null, null, null, null, null, null, null, null); + + Assert.Null(step.ErrorDetail); + Assert.Null(step.Logs); + + var line = new FlowLogLine(Guid.NewGuid(), null, DateTimeOffset.UnixEpoch, "info", "script", "m", null, null, null, null); + Assert.Single((step with { Logs = [line] }).Logs!); + } + + [Fact] + public void FlowGraph_LogTruncationMembers_DefaultToOff() + { + var graph = new FlowGraph([], [], false, false); + + Assert.False(graph.LogsTruncated); + Assert.Equal(0, graph.UnattachedLogLines); + Assert.False(FlowGraph.Empty.LogsTruncated); + Assert.Equal(0, FlowGraph.Empty.UnattachedLogLines); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs index 6e57625f..8b00af9a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Enums/AuditEnumTests.cs @@ -23,7 +23,7 @@ public class AuditEnumTests } [Fact] - public void AuditKind_HasExactlyTwentyOneExpectedMembers() + public void AuditKind_HasExactlyTwentyTwoExpectedMembers() { var expected = new[] { @@ -34,14 +34,14 @@ public class AuditEnumTests "SecuredWriteExpire", "ReconciliationAbandoned", "ManualFailover", - "RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", + "RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "ScriptLog", }; var actual = Enum.GetValues(typeof(AuditKind)) .Cast() .Select(x => x.ToString()) .ToArray(); - Assert.Equal(21, actual.Length); + Assert.Equal(22, actual.Length); Assert.Equal(expected, actual); }