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);
}