Break chart lines at collection gaps, cadence-derived per series (#1944) - #1947
Conversation
From discussion #1936: an offline monitor rendered a continuous line through the outage. TimeSeriesGaps derives each series' cadence (median positive inter-point spacing, robust against the very gaps being detected) and injects a NaN break mid-gap past 3x that spacing - ScottPlot 5 renders NaN as a line break, endpoints keep their markers. Applied via the Add.TimeSeries extension at all 104 time-series sites across both apps; synthetic zero-lines and placeholders (audited one by one) keep plain Add.Scatter because a cadence is a property of collection timestamps. Closes #1944 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
| expandedCounts.Add(0); | ||
|
|
||
| var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = BlockingTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
This series isn't a real per-collection-cadence time series — it's the synthetic "spike effect" baseline (rangeStart, time-0.0001, time, time+0.0001, ..., rangeEnd). Feeding it through Add.TimeSeries means TimeSeriesGaps.BreakAtGaps computes its "cadence" from the artificial 0.0001-day (~8.6s) offsets around each spike, which dominate the delta list whenever there are 2+ events. The median (and thus the 3x threshold) collapses toward ~0.0001 day, so any real inter-event gap — which is normally minutes to hours — exceeds the threshold and gets a NaN break inserted.
Concretely, with even two blocking events in the window, this shatters the flat "no blocking" zero-baseline into disconnected segments around each spike (I traced through the math: a 2-event example over a 6-hour window produces 3 spurious breaks — before the first event, between the two events, and after the last one). That's the exact visual regression this PR is trying to fix elsewhere: a quiet period (value 0, genuinely collected) now renders as a gap, which reads as "monitor was offline" when it wasn't.
This — and the identical pattern in RenderDeadlockTrendChart below, plus the Lite equivalents in ServerTab.Charts.cs — should probably stay on plain Add.Scatter, same as the other synthetic zero-line/placeholder sites that were deliberately left unconverted.
| expandedCounts.Add(0); | ||
|
|
||
| var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = DeadlockTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
Same issue as RenderBlockingTrendChart above: expandedTimes/expandedCounts is the synthetic zero-baseline "spike effect" array (artificial ±0.0001-day offsets around each deadlock event), not a real collection-cadence series. Running it through Add.TimeSeries lets those tiny synthetic deltas dominate the median, so genuine multi-minute/hour gaps between deadlock events get misclassified as "collection gaps" and the flat zero baseline between events gets broken up. Recommend reverting this one (and the Blocking one) to Add.Scatter.
| expandedCounts.Add(0); | ||
|
|
||
| var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = BlockingTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
Same synthetic-spike-array issue flagged in the Darling counterpart (ViewerServerTab.Blocking.cs, RenderBlockingTrendChart): expandedTimes/expandedCounts interleaves artificial ±0.0001-day offsets around each event purely for the "spike" rendering effect — it's not a real collection cadence. Routing it through Add.TimeSeries lets those tiny synthetic deltas dominate the median spacing, so real gaps between blocking events (typically minutes+) get flagged as "collection gaps" and the flat zero baseline shatters between spikes. This should stay on Add.Scatter like the other synthetic zero-line sites.
| expandedCounts.Add(0); | ||
|
|
||
| var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = DeadlockTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
Same issue as the Blocking chart above — synthetic spike-baseline array, not a real cadence series. Recommend reverting to Add.Scatter here too.
Review: #1947 (chart gap-breaking)What it does: Adds Correctness bug (flagged inline, 4 locations)
Everything else checked out
No security, injection, or missing-index concerns applicable here (pure C#/WPF charting change, no SQL). |
| expandedCounts.Add(0); | ||
|
|
||
| var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = BlockingTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
Applying Add.TimeSeries here (and at the DeadlockTrendChart site at line 524, plus the Lite mirrors in ServerTab.Charts.cs:878/957) breaks the spike-chart rendering it's added to.
expandedTimes/expandedCounts isn't a real collection-cadence series — it's a synthetic construct: for every incident it inserts (t-0.0001, 0), (t, count), (t+0.0001, 0) to draw a zero-baseline spike. Those ±0.0001-day (≈8.6s) offsets are by far the smallest deltas in the array, so TimeSeriesGaps.GapThreshold's median-of-positive-deltas locks onto ~0.0001 as soon as there are 2+ incidents (2 tiny deltas per incident vs. 1 real inter-incident gap), giving a break threshold of ~26 seconds.
Since real incidents are essentially never <26s apart, every "back to zero after incident A → zero before incident B" segment now exceeds the threshold and gets a NaN break injected — fragmenting the exact "zero baseline between spikes" this code explicitly builds (see the comment two lines up: "Build arrays with zero baseline between data points for spike effect"). The chart will render disconnected floating spikes instead of a continuous zero line between them.
This is precisely the case TimeSeriesPlotExtensions.cs's own doc comment says to exclude: "Non-time scatters (histograms, synthetic zero-lines, pixel-space annotations) keep plain Add.Scatter — a cadence is a property of collection timestamps, and deriving one from arbitrary X data would be noise." expandedTimes is exactly this kind of pixel-space/synthetic construct, not a real per-collection timestamp series, and should have stayed on plain Add.Scatter like the zeroLine fallback a few lines above it.
| expandedCounts.Add(0); | ||
|
|
||
| var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray()); | ||
| var plot = BlockingTrendChart.Plot.Add.TimeSeries(expandedTimes.ToArray(), expandedCounts.ToArray()); |
There was a problem hiding this comment.
Same issue as the Darling counterpart (ViewerServerTab.Blocking.cs:454): expandedTimes/expandedCounts is a synthetic zero-baseline/spike construct (±0.0001-day offsets around each incident), not a real collection-cadence series. Feeding it through Add.TimeSeries makes the median-derived gap threshold lock onto the tiny spike-offset spacing (~26s), so essentially every real inter-incident gap now gets a spurious NaN break — fragmenting the "zero baseline between spikes" this code builds. Same problem repeats at UpdateDeadlockTrendChart (line 957). Per TimeSeriesPlotExtensions.cs's own doc comment, synthetic/pixel-space series like this should stay on plain Add.Scatter.
| /// <c>Add.Scatter(xs, ys)</c> on ascending time data; returns the same <see cref="Scatter"/> the | ||
| /// caller styles today. | ||
| /// </summary> | ||
| public static Scatter TimeSeries(this PlottableAdder add, double[] xs, double[] ys) |
There was a problem hiding this comment.
Two other pre-existing consumers of scatter.Data.GetScatterPoints() also see the synthetic NaN break-points this injects, beyond what's exercised by the new unit tests (which only test TimeSeriesGaps.BreakAtGaps in isolation, never the interaction with existing downstream consumers):
-
Gradient area-fill is silently disabled for any series with a gap.
ChartStyle.StyleScatter(PerformanceMonitor.Ui/ChartStyle.cs:240-241, called right afterAdd.TimeSeriesat nearly every one of the ~90 converted call sites) computesminY = pts.Min(p => p.Y)/maxY = pts.Max(p => p.Y)over the full point set. .NET'sEnumerable.Min/Max<double>propagate NaN — per the documented behavior, the result isNaNif any element isNaN— so as soon as a series contains one gap,minY/maxYbecomeNaN,canFill(which requires!IsNaN(minY) && !IsNaN(maxY)) goes false, andFillYis disabled for the entire line, not just around the gap. The exact scenario this PR targets (a monitor going offline) is the one that now also silently kills that series' gradient fill for its whole rendered range. -
CSV chart export gets fabricated rows. Both
Lite/Helpers/ContextMenuHelper.cs:291andDarling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs:305iteratescatter.Data.GetScatterPoints()to write one CSV row per point for everyScatterplottable on the chart. The injected gap-break point (a fabricated mid-gap timestamp withY = NaN) is a real entry in that collection, so exporting data from any chart with a detected gap now writes out a row with a timestamp that was never actually collected and a literal "NaN" value string — polluting exported data for a tool whose purpose is accurate historical reporting.
Neither of these is caught by the new TimeSeriesGapsTests, since those only exercise BreakAtGaps directly and never touch ChartStyle or the CSV export path.
Review: #1947 — break chart lines at collection gapsWhat it does: Adds ParityNo drift found. The algorithm and extension live in the shared Correctness — 3 issues found, all left as inline comments
All three stem from the same root cause: the new NaN break-points flow into every existing consumer of Core algorithm itself
Not flaggedNo missing-index DMV concerns (n/a to this change), no security-relevant input/secret/process handling in this diff, no obvious perf regression — the added per-render cost (sort of deltas, O(n) array rebuild) is negligible at chart-series sizes. |
… and CSV export ignore gap markers The name-based sweep converted four synthetic spike-baseline arrays (expandedTimes interleaves artificial 0.0001-day offsets around each incident) whose fake deltas would dominate the cadence median and shatter the zero baseline - reverted with the why on each. Two real downstream defects fixed: Enumerable.Min/Max propagate NaN, so one gap marker silently killed a series' entire gradient fill (real values now rank); and both CSV exports wrote the fabricated mid-gap NaN row - exports now carry only collected data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| var avgValues = PadEnds(ordered.Select(d => d.AvgDurationMs).ToArray(), 0, 0); | ||
|
|
||
| var maxPlot = BlockingDurationChart.Plot.Add.Scatter(times, maxValues); | ||
| var maxPlot = BlockingDurationChart.Plot.Add.TimeSeries(times, maxValues); |
There was a problem hiding this comment.
Cadence-based gap-breaking applied to a sparse, incident-driven series — worth confirming this was deliberate.
GetBlockingDurationStatsAsync only emits a row for minutes where blocking actually happened (GROUP BY DATE_TRUNC('minute', event_time) over v_dmv_blocking_snapshots), and PadEnds exists specifically to bridge that sparsity across the full window with zero-value endpoints — the doc comment above literally calls it "the window-pinned analog of the Trends charts' zero-baseline expansion." That's the same shape as the expandedTimes/expandedCounts synthetic baseline in RenderBlockingTrendChart/RenderDeadlockTrendChart a few hundred lines up, which this PR deliberately kept on plain Add.Scatter with a comment explaining that cadence-based breaking "would lock onto the artificial deltas."
Here the median-spacing cadence gets derived from a mix of genuinely irregular incident-to-incident gaps plus the two synthetic pad-to-real deltas, then used to insert breaks. Depending on how bursty the incidents are relative to the window, that can either correctly sever the zero-baseline pad from a cluster of activity, or spuriously shatter the line inside a single blocking episode whose sub-gaps happen to exceed 3x the local median — blocking/deadlock incidents aren't collected on a steady cadence, so a wide gap here doesn't reliably mean "collector was offline" the way it does for the CPU/memory series this feature targets.
Same pattern repeats at BlockingTotalDurationChart (below), DeadlockWaitChart, and DeadlockTotalWaitChart in this file, and identically in Lite/Controls/ServerTab.BlockingStats.cs. Given the sibling zero-baseline charts were explicitly excluded, this looks like it may have been missed in the per-site audit rather than intentionally included — worth a second look before merge.
| var avgValues = PadEnds(ordered.Select(d => d.AvgDurationMs).ToArray(), 0, 0); | ||
|
|
||
| var maxPlot = BlockingDurationChart.Plot.Add.Scatter(times, maxValues); | ||
| var maxPlot = BlockingDurationChart.Plot.Add.TimeSeries(times, maxValues); |
There was a problem hiding this comment.
Same concern as the Darling counterpart (ViewerServerTab.Blocking.cs, RenderBlockingDurationChart): this is a PadEnds-bridged sparse/incident-driven series (blocking only produces rows when it occurs), same shape as the expandedTimes/expandedCounts baseline that was deliberately excluded from Add.TimeSeries a bit further down in this file for exactly that reason. Applying cadence-derived gap-breaking here risks shattering the line inside a single blocking episode, since incident-to-incident gaps aren't a steady collection cadence. Repeats at BlockingTotalDurationChart, DeadlockWaitChart, DeadlockTotalWaitChart in this file — flagging once here for parity with the Darling comment.
Review: #1947 — Break chart lines at collection gapsOverview. Adds Core algorithm. Lite/Darling parity. Checked this directly: grepped every remaining Correctness concern (inline comments posted). The same "synthetic-baseline, exclude from cadence-breaking" reasoning that was applied to the incident-count trend charts doesn't appear to have been carried over to the sibling incident-duration/wait charts ( Minor / non-blocking:
Nice fix for a real, well-motivated UX problem (offline windows reading as continuous data), and the mechanism itself is careful and well-tested. The one thing I'd want resolved before merge is the PadEnds/incident-chart question above. |
From discussion #1936 (markallisongit): offline windows rendered as continuous lines. Mechanism: TimeSeriesGaps.BreakAtGaps - median positive inter-point spacing per series (robust: the outage gaps cannot inflate the yardstick), NaN break injected mid-gap past 3x, ScottPlot 5.1.59 renders NaN as the documented gap. Applied through the Add.TimeSeries extension at 104 time-series sites in both apps symmetrically; the ~20 remaining Add.Scatter sites are synthetic zero-lines/placeholders, audited individually. Ten unit tests pin both directions (outages break; jitter, duplicate timestamps, short series, and slow-cadence series stay connected). Both apps rebuild 0 warnings; Lite 1947/1947.
Note for Erik: this changes how every time chart renders - worth a visual pass on the dev build, and the client briefing tells the site to report any break that does not match a real outage.
🤖 Generated with Claude Code