Flow Debugger · v1.4 proposal · 2026-09-09

Exceptions and log lines on the step that produced them

A traced run carries every Log.* line a script writes, every warning the runtime logs on the script's behalf, and the full exception a failing step threw. Each lands on its own node: a count badge on the node, a Log tab in the detail panel, and run-wide error and warning counts in the status strip. Live and Replay read the same rows.

Inbound API methodSite script
DelmiaRecipeDownload
LiveReplay
Edit inputsRun
Failed Run#7 Started14:02:11.198Z Total1 842 ms Steps8 Boundaries2 Writes2 Log1 error · 2 warnings · 4 info Refresh outcomes Root7c1e0f2a-…-9b41
Centralcentral-a Sitesite-a · DelmiaSim01 TagsDelmiaReceiver.* ExternalMesAudit
Inbound1 842 ms
DelmiaRecipeDownload
flow-debugger · jdoherty≡ 1
Route call1 790 ms
DelmiaSim01.ProcessRecipeDownload
Script failed at site
Script run1 731 ms
ProcessRecipeDownload
InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)✕ 1! 1≡ 2
Write12 ms
RecipeId, RecipeName
RecipeId = 42, RecipeName = …! 1
Write9 ms
DownloadRequest
DownloadRequest = true
Wait1 640 ms
RecipeProcessedFlag
satisfied · true
Database23 ms
MesAudit
Ok· write · 1 row≡ 1
Response
Response
500 · SCRIPT_ERROR
Script run · Site

ProcessRecipeDownload

DelmiaSim01.DelmiaReceiver · call
Failed
InputOutputLog5WritesTiming
System.InvalidOperationException
Recipe 42 rejected by Galaxy (code 7)
   at Submission#0.<<Initialize>>d__0.MoveNext() in ProcessRecipeDownload:line 41
   at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync(…)
Inner exception: none
CopyOpen in Audit LogThrown by the script body · site-a / node-a
AllWarn +
5 lines on this step · 1 more on Write RecipeId, RecipeName Copy lines
Time (UTC)LevelSourceMessage
14:02:11.204infoscriptRecipe 42 requested for line L3 by flow-debugger
14:02:11.206debugscriptWriting header attributes then DownloadRequest
14:02:12.861infoscriptGalaxy answered: RecipeProcessedFlag = true, ResultCode = 7
14:02:12.862warnscriptResultCode 7 is outside the accepted set {0, 1}
14:02:12.870errorscriptRejecting recipe 42 (code 7) — see exception above
14:02:12.929warnruntimeLog truncated inline: 0 lines dropped (shown only when the per-run cap is hit)
Where this ran
Site / node
site-a / node-a
Instance
DelmiaSim01
Target
DelmiaReceiver.ProcessRecipeDownload
Trigger
call
Log lines
5 here · 1 on other steps
Open in Debug ViewAudit rows (9)

Node anatomy

Nothing moves on a node that has no log lines. A node with lines gains a count badge in the footer, highest severity first; a failed node swaps its subtitle for the exception's type and message.

Inbound1 842 ms
DelmiaRecipeDownload
flow-debugger · jdoherty≡ 1
Info only. A faint ≡ n badge. Debug lines count here too; the tab's Warn + filter hides them.
Write12 ms
RecipeId, RecipeName
RecipeId = 42, RecipeName = …! 1
Runtime warning on a step. The write helper's own log line ("acked with quality Uncertain") lands on the write node, not on the script run, because the helper was inside its step scope when it logged.
Script run1 731 ms
ProcessRecipeDownload
InvalidOperationException: Recipe 42 rejected …✕ 1! 1≡ 2
Failed with an exception. Red stroke and eyebrow as today; the subtitle becomes Type: message. Selecting it opens the Log tab first.

What the script author writes

One new global, Log, on every script surface (site scripts, shared scripts, inbound API methods, the Test Run editor). Always forwards to the node's logger; becomes a trace row only when the run is traced, so untraced production runs add no audit volume.

// ProcessRecipeDownload — site Call script (excerpt)
var recipeId = Parameters.Get<int>("RecipeId");
Log.Info($"Recipe {recipeId} requested for line {Parameters.Get<string>("Line")} by {Parameters.Get<string>("Requester")}");
Log.Debug("Writing header attributes then DownloadRequest");

await Children["DelmiaReceiver"].SetAsync("RecipeId", recipeId);
// … writes, wait …

var code = await Children["DelmiaReceiver"].GetAsync<int>("ResultCode");
Log.Info($"Galaxy answered: RecipeProcessedFlag = true, ResultCode = {code}");
if (code is not (0 or 1))
{
    Log.Warn($"ResultCode {code} is outside the accepted set {{0, 1}}");
    Log.Error($"Rejecting recipe {recipeId} (code {code})");
    throw new InvalidOperationException($"Recipe {recipeId} rejected by Galaxy (code {code})");
}
Log.Warn("Recoverable problem", caughtException);   // an exception on a line renders as an expandable chip

Where a line lands

Produced bySourceLands onHow
Log.* in a site script (incl. inline shared scripts)scriptThat execution's Script run nodeOne ScriptTrace.ScriptLog row per line, keyed by the run's ExecutionId; no step id
Log.* in an inbound API method scriptscriptThe Inbound node (or the debugger's route-call root for a script entry)Same row, written centrally with the inbound ExecutionId; inline on a Live run through the runner's collector
The runtime's own warnings while a helper step runs (write, wait, sync API call)runtimeThat step's nodeThe helper pre-mints its step's event id and holds it in an ambient scope; the context's logger copies Information + lines into a row stamped stepEventId
Runtime warnings outside any helper stepruntimeThe Script run nodeNo scope active → falls back to the execution anchor
The exception that failed a script run—The Script run node's exception blockNot a log row: ScriptRun.errorDetail now carries ex.ToString() (type, message, stack, inner); the inbound row gets the same for a central method failure
Log lines from other actors on the node (instance actor, DCL)—Not captured in v1No execution-id enrichment exists on the node loggers; recorded as the follow-up, not attempted here

Decision notes

  1. Lines are audit rows, not a side channel. A new ScriptLog kind in the existing ScriptTrace channel rides the collector for Live and the audit store for Replay, with redaction, retention and the site 7-day purge applying unchanged. The graph builder folds them into their owning step; they are never drawn as nodes.
  2. Untraced runs pay nothing. Log.* always writes to the node's ILogger at the given level (so it shows in Serilog and the site log as usual) and emits a row only when the run is traced. TraceAllCalls methods therefore gain lines in Replay at ~1 row per line.
  3. Bounded by design. A per-run cap (200 lines, configurable) and a 2 000-character message cap sit at the API; past the cap lines are dropped and one closing runtime warning names the count. Inline, log rows are low-priority in the collector: they stop 50 events short of the 500 cap so the step rows emitted at run end are never crowded out, and the page shows log lines truncated inline — Replay is complete when that happens.
  4. Attribution is explicit, never inferred from time. A row carries stepEventId or it belongs to the execution's anchor step. Steps whose row is written later by another actor (cached calls, notifications) keep the fallback.
  5. Exceptions stay where they are already recorded. The failure row exists on every path today; it only lacked the detail. Adding errorDetail costs one argument on the site and one internal field centrally; the HTTP caller still receives the safe SCRIPT_ERROR body.
  6. Selecting a failed node opens the Log tab. Every other node keeps Input as its first tab; the Output tab's red alert stays and links across.
  7. Not in this slice: live streaming of lines during a long run (Live rows still arrive with the reply), a run-wide chronological log view (the register's Direction B list would host it), and node-logger enrichment by execution id.