Counter instruments display absolute values now in dotnet-counters (#4455)
authorNoah Falk <noahfalk@users.noreply.github.com>
Tue, 16 Jan 2024 23:46:31 +0000 (15:46 -0800)
committerGitHub <noreply@github.com>
Tue, 16 Jan 2024 23:46:31 +0000 (15:46 -0800)
Fixes #3751

Previously we always showed counter instruments as a rate, but
for a variety of counters that isn't the most useful value. Now
we are showing the absolute value as long as the target process
is running .NET 8. For cases where it was useful to see rate of change
there is now a --showDeltas option which enables a 2nd column
of output for dotnet-counters monitor. The deltas show the difference
between the previous value and the current value. Making the new
column optional rather than a default setting is intended to minimize
consuming horizontal line space which is already at a premium. When
using csv or json outputs it is assumed the user's tool that processes
the data later can compute the rate if desired so there is no special
delta handling added for those.

Take a look at the updated exporter tests to see the specific changes
in dotnet-counters output format.

For other tools consuming System.Diagnostics.Monitoring.EventPipe
there is a new setting on the settings object that allows opting in to
the new behavior. I'm guessing dotnet-monitor will want to do it too
but I wanted to ensure they can opt-into the new behavior at a
time of their choosing.

src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/CounterPayload.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/MetricsPipeline.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/MetricsPipelineSettings.cs
src/Microsoft.Diagnostics.Monitoring.EventPipe/Counters/TraceEventExtensions.cs
src/Tools/dotnet-counters/CounterMonitor.cs
src/Tools/dotnet-counters/Exporters/ConsoleWriter.cs
src/Tools/dotnet-counters/Program.cs
src/tests/dotnet-counters/CSVExporterTests.cs
src/tests/dotnet-counters/ConsoleExporterTests.cs
src/tests/dotnet-counters/JSONExporterTests.cs
src/tests/dotnet-counters/MockConsole.cs

index 83ed76611c9d37966109ed3327b18d3ccae107da..e783efad15866227baa59322a7eeb0923bf7caa1 100644 (file)
@@ -127,10 +127,15 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
         }
     }
 
+    /// <summary>
+    /// This gets generated for Counter instruments from Meters. This is used for pre-.NET 8 versions of MetricsEventSource that only reported rate and not absolute value,
+    /// or for any tools that haven't opted into using RateAndValuePayload in the CounterConfiguration settings.
+    /// </summary>
     internal sealed class RatePayload : MeterPayload
     {
-        public RatePayload(CounterMetadata counterMetadata, string displayName, string displayUnits, string valueTags, double value, double intervalSecs, DateTime timestamp) :
-            base(timestamp, counterMetadata, displayName, displayUnits, value, CounterType.Rate, valueTags, EventType.Rate)
+
+        public RatePayload(CounterMetadata counterMetadata, string displayName, string displayUnits, string valueTags, double rate, double intervalSecs, DateTime timestamp) :
+            base(timestamp, counterMetadata, displayName, displayUnits, rate, CounterType.Rate, valueTags, EventType.Rate)
         {
             // In case these properties are not provided, set them to appropriate values.
             string counterName = string.IsNullOrEmpty(displayName) ? counterMetadata.CounterName : displayName;
@@ -140,6 +145,25 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
         }
     }
 
+    /// <summary>
+    /// Starting in .NET 8, MetricsEventSource reports counters with both absolute value and rate. If enabled in the CounterConfiguration and the new value field is present
+    /// then this payload will be created rather than the older RatePayload. Unlike RatePayload, this one treats the absolute value as the primary statistic.
+    /// </summary>
+    internal sealed class CounterRateAndValuePayload : MeterPayload
+    {
+        public CounterRateAndValuePayload(CounterMetadata counterMetadata, string displayName, string displayUnits, string valueTags, double rate, double value, DateTime timestamp) :
+            base(timestamp, counterMetadata, displayName, displayUnits, value, CounterType.Metric, valueTags, EventType.Rate)
+        {
+            // In case these properties are not provided, set them to appropriate values.
+            string counterName = string.IsNullOrEmpty(displayName) ? counterMetadata.CounterName : displayName;
+            string unitsName = string.IsNullOrEmpty(displayUnits) ? "Count" : displayUnits;
+            DisplayName = $"{counterName} ({unitsName})";
+            Rate = rate;
+        }
+
+        public double Rate { get; private set; }
+    }
+
     internal record struct Quantile(double Percentage, double Value);
 
     internal sealed class PercentilePayload : MeterPayload
index 9f8f34c1505acc5a7ee45cbc90b1060af1f9d087..65c2ba2882aee35371ed6e7b61013cad4589c146 100644 (file)
@@ -57,7 +57,8 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
                 SessionId = _sessionId,
                 ClientId = _clientId,
                 MaxHistograms = Settings.MaxHistograms,
-                MaxTimeseries = Settings.MaxTimeSeries
+                MaxTimeseries = Settings.MaxTimeSeries,
+                UseCounterRateAndValuePayload = Settings.UseCounterRateAndValuePayloads
             };
 
             return config;
index 4e9c4f83f4b834218f82c569c1229ebb76f1ee90..472d400234cd857367cd04d8e21bef4b33405c3c 100644 (file)
@@ -18,6 +18,11 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
         public int MaxTimeSeries { get; set; }
 
         public bool UseSharedSession { get; set; }
+
+        // Starting in .NET 8 MetricsEventSource reports both absolute value and rate for Counter instruments
+        // If this is false the pipeline will produce RatePayload objects
+        // If this is true and the new absolute value field is available the pipeline will produce CounterRateAndValuePayload instead
+        public bool UseCounterRateAndValuePayloads { get; set; }
     }
 
     [Flags]
index f69dc85b5a59e438c3e02d233dd84c81278cf35a..7bd7e3611a5f7861f3eb1eeb373c9f79aa288b7f 100644 (file)
@@ -25,6 +25,11 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
         public int MaxHistograms { get; set; }
 
         public int MaxTimeseries { get; set; }
+
+        // Starting in .NET 8 MetricsEventSource reports both absolute value and rate for Counter instruments
+        // If this is false the pipeline will produce RatePayload objects
+        // If this is true the pipeline will produce CounterRateAndValuePayload instead if value field is available
+        public bool UseCounterRateAndValuePayload { get; set; }
     }
 
     internal record struct ProviderAndCounter(string ProviderName, string CounterName);
@@ -249,15 +254,30 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe
             string unit = (string)traceEvent.PayloadValue(4);
             string tags = (string)traceEvent.PayloadValue(5);
             string rateText = (string)traceEvent.PayloadValue(6);
+            //Starting in .NET 8 we also publish the absolute value of these counters
+            string absoluteValueText = null;
+            if (traceEvent.Version >= 1)
+            {
+                absoluteValueText = (string)traceEvent.PayloadValue(7);
+            }
 
             if (!counterConfiguration.CounterFilter.IsIncluded(meterName, instrumentName))
             {
                 return;
             }
 
-            if (double.TryParse(rateText, NumberStyles.Number | NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
+            if (double.TryParse(rateText, NumberStyles.Number | NumberStyles.Float, CultureInfo.InvariantCulture, out double rate))
             {
-                payload = new RatePayload(GetCounterMetadata(meterName, instrumentName), null, unit, tags, value, counterConfiguration.CounterFilter.DefaultIntervalSeconds, traceEvent.TimeStamp);
+                if (absoluteValueText != null &&
+                    counterConfiguration.UseCounterRateAndValuePayload &&
+                    double.TryParse(absoluteValueText, NumberStyles.Number | NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
+                {
+                    payload = new CounterRateAndValuePayload(GetCounterMetadata(meterName, instrumentName), null, unit, tags, rate, value, traceEvent.TimeStamp);
+                }
+                else
+                {
+                    payload = new RatePayload(GetCounterMetadata(meterName, instrumentName), null, unit, tags, rate, counterConfiguration.CounterFilter.DefaultIntervalSeconds, traceEvent.TimeStamp);
+                }
             }
             else
             {
index ab72fa0c09e4022e9cfb3efa33945a1b651d4eb1..5f1ff39d68155290e161ae175c3ea8bcd9925124 100644 (file)
@@ -166,7 +166,8 @@ namespace Microsoft.Diagnostics.Tools.Counters
             bool resumeRuntime,
             int maxHistograms,
             int maxTimeSeries,
-            TimeSpan duration)
+            TimeSpan duration,
+            bool showDeltas)
         {
             try
             {
@@ -197,7 +198,7 @@ namespace Microsoft.Diagnostics.Tools.Counters
                         // the launch command may misinterpret app arguments as the old space separated
                         // provider list so we need to ignore it in that case
                         _counterList = ConfigureCounters(counters, _processId != 0 ? counter_list : null);
-                        _renderer = new ConsoleWriter(new DefaultConsole(useAnsi));
+                        _renderer = new ConsoleWriter(new DefaultConsole(useAnsi), showDeltaColumn:showDeltas);
                         _diagnosticsClient = holder.Client;
                         _settings = new MetricsPipelineSettings();
                         _settings.Duration = duration == TimeSpan.Zero ? Timeout.InfiniteTimeSpan : duration;
@@ -206,6 +207,7 @@ namespace Microsoft.Diagnostics.Tools.Counters
                         _settings.CounterIntervalSeconds = refreshInterval;
                         _settings.ResumeRuntime = resumeRuntime;
                         _settings.CounterGroups = GetEventPipeProviders();
+                        _settings.UseCounterRateAndValuePayloads = true;
 
                         bool useSharedSession = false;
                         if (_diagnosticsClient.GetProcessInfo().TryGetProcessClrVersion(out Version version))
index eaf084e74058d853b6b349ca866d625ea5940e64..954b498ee97e1496171a4e9530a269e05a8b7958 100644 (file)
@@ -29,13 +29,8 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             public readonly CounterProvider KnownProvider;
         }
 
-        private interface ICounterRow
-        {
-            int Row { get; set; }
-        }
-
         /// <summary>Information about an observed counter.</summary>
-        private class ObservedCounter : ICounterRow
+        private class ObservedCounter
         {
             public ObservedCounter(string displayName) => DisplayName = displayName;
             public string DisplayName { get; } // Display name for this counter.
@@ -45,9 +40,10 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             public bool RenderValueInline => TagSets.Count == 0 ||
                        (TagSets.Count == 1 && string.IsNullOrEmpty(TagSets.Keys.First()));
             public double LastValue { get; set; }
+            public double? LastDelta { get; set; }
         }
 
-        private class ObservedTagSet : ICounterRow
+        private class ObservedTagSet
         {
             public ObservedTagSet(string tags)
             {
@@ -57,14 +53,16 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             public string DisplayTags => string.IsNullOrEmpty(Tags) ? "<no tags>" : Tags;
             public int Row { get; set; } // Assigned row for this counter. May change during operation.
             public double LastValue { get; set; }
+            public double? LastDelta { get; set; }
         }
 
         private readonly object _lock = new();
         private readonly Dictionary<string, ObservedProvider> _providers = new(); // Tracks observed providers and counters.
+        private readonly bool _showDeltaColumn;
         private const int Indent = 4; // Counter name indent size.
         private const int CounterValueLength = 15;
 
-        private int _maxNameLength;
+        private int _nameColumnWidth; // fixed width of the name column. Names will be truncated if needed to fit in this space.
         private int _statusRow; // Row # of where we print the status of dotnet-counters
         private int _topRow;
         private bool _paused;
@@ -77,9 +75,10 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
         private int _consoleWidth = -1;
         private IConsole _console;
 
-        public ConsoleWriter(IConsole console)
+        public ConsoleWriter(IConsole console, bool showDeltaColumn = false)
         {
             _console = console;
+            _showDeltaColumn = showDeltaColumn;
         }
 
         public void Initialize()
@@ -126,7 +125,9 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
 
             _consoleWidth = _console.WindowWidth;
             _consoleHeight = _console.WindowHeight;
-            _maxNameLength = Math.Max(Math.Min(80, _consoleWidth) - (CounterValueLength + Indent + 1), 0); // Truncate the name to prevent line wrapping as long as the console width is >= CounterValueLength + Indent + 1 characters
+            // Truncate the name column if needed to prevent line wrapping
+            int numValueColumns = _showDeltaColumn ? 2 : 1;
+            _nameColumnWidth = Math.Max(Math.Min(80, _consoleWidth) - numValueColumns * (CounterValueLength + 1), 0);
 
 
             int row = _console.CursorTop;
@@ -141,62 +142,36 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                 row += GetLineWrappedLines(_errorText);
             }
 
-            bool RenderRow(ref int row, string lineOutput = null, ICounterRow counterRow = null)
-            {
-                if (row >= _consoleHeight + _topRow) // prevents from displaying more counters than vertical space available
-                {
-                    return false;
-                }
-
-                if (lineOutput != null)
-                {
-                    _console.Write(lineOutput);
-                }
-
-                if (row < _consoleHeight + _topRow - 1) // prevents screen from scrolling due to newline on last line of console
-                {
-                    _console.WriteLine();
-                }
-
-                if (counterRow != null)
-                {
-                    counterRow.Row = row;
-                }
-
-                row++;
-
-                return true;
-            }
-
-            if (RenderRow(ref row)) // Blank line.
+            if (RenderRow(ref row) &&                                            // Blank line.
+                RenderTableRow(ref row, "Name", "Current Value", "Last Delta"))  // Table header
             {
                 foreach (ObservedProvider provider in _providers.Values.OrderBy(p => p.KnownProvider == null).ThenBy(p => p.Name)) // Known providers first.
                 {
-                    if (!RenderRow(ref row, $"[{provider.Name}]"))
+                    if (!RenderTableRow(ref row, $"[{provider.Name}]"))
                     {
                         break;
                     }
 
                     foreach (ObservedCounter counter in provider.Counters.Values.OrderBy(c => c.DisplayName))
                     {
-                        string name = MakeFixedWidth($"{new string(' ', Indent)}{counter.DisplayName}", Indent + _maxNameLength);
+                        counter.Row = row;
                         if (counter.RenderValueInline)
                         {
-                            if (!RenderRow(ref row, $"{name} {FormatValue(counter.LastValue)}", counter))
+                            if (!RenderCounterValueRow(ref row, indentLevel:1, counter.DisplayName, counter.LastValue, 0))
                             {
                                 break;
                             }
                         }
                         else
                         {
-                            if (!RenderRow(ref row, name, counter))
+                            if (!RenderCounterNameRow(ref row, counter.DisplayName))
                             {
                                 break;
                             }
                             foreach (ObservedTagSet tagSet in counter.TagSets.Values.OrderBy(t => t.Tags))
                             {
-                                string tagName = MakeFixedWidth($"{new string(' ', 2 * Indent)}{tagSet.Tags}", Indent + _maxNameLength);
-                                if (!RenderRow(ref row, $"{tagName} {FormatValue(tagSet.LastValue)}", tagSet))
+                                tagSet.Row = row;
+                                if (!RenderCounterValueRow(ref row, indentLevel: 2, tagSet.Tags, tagSet.LastValue, 0))
                                 {
                                     break;
                                 }
@@ -251,21 +226,30 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                 {
                     string displayName = payload.GetDisplay();
                     provider.Counters[name] = counter = new ObservedCounter(displayName);
-                    _maxNameLength = Math.Max(_maxNameLength, displayName.Length);
-                    if (tags != null)
-                    {
-                        counter.LastValue = payload.Value;
-                    }
                     redraw = true;
                 }
+                else
+                {
+                    counter.LastDelta = payload.Value - counter.LastValue;
+                }
 
                 ObservedTagSet tagSet = null;
-                if (tags != null && !counter.TagSets.TryGetValue(tags, out tagSet))
+                if (string.IsNullOrEmpty(tags))
+                {
+                    counter.LastValue = payload.Value;
+                }
+                else
                 {
-                    counter.TagSets[tags] = tagSet = new ObservedTagSet(tags);
-                    _maxNameLength = Math.Max(_maxNameLength, tagSet.DisplayTags.Length);
+                    if (!counter.TagSets.TryGetValue(tags, out tagSet))
+                    {
+                        counter.TagSets[tags] = tagSet = new ObservedTagSet(tags);
+                        redraw = true;
+                    }
+                    else
+                    {
+                        tagSet.LastDelta = payload.Value - tagSet.LastValue;
+                    }
                     tagSet.LastValue = payload.Value;
-                    redraw = true;
                 }
 
                 if (_console.WindowWidth != _consoleWidth || _console.WindowHeight != _consoleHeight)
@@ -277,14 +261,17 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
                 {
                     AssignRowsAndInitializeDisplay();
                 }
-
-                int row = counter.RenderValueInline ? counter.Row : tagSet.Row;
-                if (row < 0)
+                else
                 {
-                    return;
+                    if (tagSet != null)
+                    {
+                        IncrementalUpdateCounterValueRow(tagSet.Row, tagSet.LastValue, tagSet.LastDelta.Value);
+                    }
+                    else
+                    {
+                        IncrementalUpdateCounterValueRow(counter.Row, counter.LastValue, counter.LastDelta.Value);
+                    }
                 }
-                _console.SetCursorPosition(Indent + _maxNameLength + 1, row);
-                _console.Write(FormatValue(payload.Value));
             }
         }
 
@@ -342,6 +329,81 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             return lineCount;
         }
 
+        private bool RenderCounterValueRow(ref int row, int indentLevel, string name, double value, double? delta)
+        {
+            // if you change line formatting, keep it in sync with IncrementaUpdateCounterValueRow below
+            string deltaText = delta.HasValue ? "" : FormatValue(delta.Value);
+            return RenderTableRow(ref row, $"{new string(' ', Indent * indentLevel)}{name}", FormatValue(value), deltaText);
+        }
+
+        private bool RenderCounterNameRow(ref int row, string name)
+        {
+            return RenderTableRow(ref row, $"{new string(' ', Indent)}{name}");
+        }
+
+        private bool RenderTableRow(ref int row, string name, string value = null, string delta = null)
+        {
+            // if you change line formatting, keep it in sync with IncrementaUpdateCounterValueRow below
+            string nameCellText = MakeFixedWidth(name, _nameColumnWidth);
+            string valueCellText = MakeFixedWidth(value, CounterValueLength, alignRight: true);
+            string deltaCellText = MakeFixedWidth(delta, CounterValueLength, alignRight: true);
+            string lineText;
+            if (_showDeltaColumn)
+            {
+                lineText = $"{nameCellText} {valueCellText} {deltaCellText}";
+            }
+            else
+            {
+                lineText = $"{nameCellText} {valueCellText}";
+            }
+            return RenderRow(ref row, lineText);
+        }
+
+        private bool RenderRow(ref int row, string text = null)
+        {
+            if (row >= _consoleHeight + _topRow) // prevents from displaying more counters than vertical space available
+            {
+                return false;
+            }
+
+            if (text != null)
+            {
+                _console.Write(text);
+            }
+
+            if (row < _consoleHeight + _topRow - 1) // prevents screen from scrolling due to newline on last line of console
+            {
+                _console.WriteLine();
+            }
+
+            row++;
+
+            return true;
+        }
+
+        private void IncrementalUpdateCounterValueRow(int row, double value, double delta)
+        {
+            // prevents from displaying more counters than vertical space available
+            if (row < 0 || row >= _consoleHeight + _topRow)
+            {
+                return;
+            }
+
+            _console.SetCursorPosition(_nameColumnWidth + 1, row);
+            string valueCellText = MakeFixedWidth(FormatValue(value), CounterValueLength);
+            string deltaCellText = MakeFixedWidth(FormatValue(delta), CounterValueLength);
+            string partialLineText;
+            if (_showDeltaColumn)
+            {
+                partialLineText = $"{valueCellText} {deltaCellText}";
+            }
+            else
+            {
+                partialLineText = $"{valueCellText}";
+            }
+            _console.Write(partialLineText);
+        }
+
         private static string FormatValue(double value)
         {
             string valueText;
@@ -382,9 +444,13 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             return valueText;
         }
 
-        private static string MakeFixedWidth(string text, int width)
+        private static string MakeFixedWidth(string text, int width, bool alignRight = false)
         {
-            if (text.Length == width)
+            if (text == null)
+            {
+                return new string(' ', width);
+            }
+            else if (text.Length == width)
             {
                 return text;
             }
@@ -394,7 +460,14 @@ namespace Microsoft.Diagnostics.Tools.Counters.Exporters
             }
             else
             {
-                return text += new string(' ', width - text.Length);
+                if (alignRight)
+                {
+                    return new string(' ', width - text.Length) + text;
+                }
+                else
+                {
+                    return text + new string(' ', width - text.Length);
+                }
             }
         }
 
index d1fde42e6fdd07a4042fd5b6ad047335c935a01d..da00ca0784986ded2f1cd57f763f40a17d108f8d 100644 (file)
@@ -49,7 +49,8 @@ namespace Microsoft.Diagnostics.Tools.Counters
             bool resumeRuntime,
             int maxHistograms,
             int maxTimeSeries,
-            TimeSpan duration);
+            TimeSpan duration,
+            bool showDeltas);
 
         private static Command MonitorCommand() =>
             new(
@@ -68,7 +69,8 @@ namespace Microsoft.Diagnostics.Tools.Counters
                 ResumeRuntimeOption(),
                 MaxHistogramOption(),
                 MaxTimeSeriesOption(),
-                DurationOption()
+                DurationOption(),
+                ShowDeltasOption()
             };
 
         private static Command CollectCommand() =>
@@ -207,6 +209,13 @@ namespace Microsoft.Diagnostics.Tools.Counters
                 Argument = new Argument<TimeSpan>(name: "duration-timespan", getDefaultValue: () => default)
             };
 
+        private static Option ShowDeltasOption() =>
+            new(
+                alias: "--showDeltas",
+                description: @"Shows an extra column in the metrics table that displays the delta between the previous metric value and the most recent value." +
+               " This is useful to monitor the rate of change for a metric.")
+            { };
+
         private static readonly string[] s_SupportedRuntimeVersions = KnownData.s_AllVersions;
 
         public static int List(IConsole console, string runtimeVersion)
index db4c6ff3561528518453cd434a54ca49a6800ceb..9bd4b6e52408aef57f5429641a791f4320cb979e 100644 (file)
@@ -109,6 +109,47 @@ namespace DotnetCounters.UnitTests
             }
         }
 
+        // Starting in .NET 8 MetricsEventSource, Meter counter instruments report both rate of change and
+        // absolute value. Reporting rate in the UI was less useful for many counters than just seeing the raw
+        // value. Now dotnet-counters reports these counters as absolute values.
+        [Fact]
+        public void CounterReportsAbsoluteValuePostNet8()
+        {
+            string fileName = "CounterReportsAbsoluteValuePostNet8.csv";
+            CSVExporter exporter = new(fileName);
+            exporter.Initialize();
+            DateTime start = DateTime.Now;
+            for (int i = 0; i < 100; i++)
+            {
+                exporter.CounterPayloadReceived(new CounterRateAndValuePayload(new CounterMetadata("myProvider", "counter", null, null, null), "Counter One", string.Empty, string.Empty, rate:0, i, start + TimeSpan.FromSeconds(i)), false);
+            }
+            exporter.Stop();
+
+            Assert.True(File.Exists(fileName));
+
+            try
+            {
+                List<string> lines = File.ReadLines(fileName).ToList();
+                Assert.Equal(101, lines.Count); // should be 101 including the headers
+
+                ValidateHeaderTokens(lines[0]);
+
+                for (int i = 1; i < lines.Count; i++)
+                {
+                    string[] tokens = lines[i].Split(',');
+
+                    Assert.Equal("myProvider", tokens[1]);
+                    Assert.Equal($"Counter One (Count)", tokens[2]);
+                    Assert.Equal("Metric", tokens[3]);
+                    Assert.Equal((i - 1).ToString(), tokens[4]);
+                }
+            }
+            finally
+            {
+                File.Delete(fileName);
+            }
+        }
+
         [Fact]
         public void CounterTest_SameMeterDifferentTagsPerInstrument()
         {
index 4fae980706efd6402263502d0b84af8ac8b61ef3..381ccc5707ccac96e0b72477ae52efee8f9c516c 100644 (file)
@@ -36,6 +36,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12");
         }
@@ -51,6 +52,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
         }
@@ -67,6 +69,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    Allocation Rate (B / 1 sec)                    1,731",
                                      "[Provider2]",
@@ -86,6 +89,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
@@ -96,6 +100,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                     7",
                                      "    Allocation Rate (B / 1 sec)                  123,456");
@@ -114,6 +119,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
@@ -123,6 +129,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Paused",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
@@ -133,6 +140,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Paused",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
@@ -142,6 +150,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                    12",
                                      "    Allocation Rate (B / 1 sec)                    1,731");
@@ -153,6 +162,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                     1",
                                      "    Allocation Rate (B / 1 sec)                        2");
@@ -171,6 +181,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                           Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)                     0.1",
                                      "    Allocation Rate (B / 1 sec)                    1,731",
@@ -189,6 +200,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                 Current Value",
                                      "[System.Runtime]",
                                      "    % Time in GC since last GC (%)           0.1",
                                      "    Allocation Rate (B / 1 sec)          1,731");
@@ -205,6 +217,7 @@ namespace DotnetCounters.UnitTests
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                 Current Value",
                                      "[System.Runtime]",
                                      "    ThisCounterHasAVeryLongNameTha           0.1");
         }
@@ -216,13 +229,14 @@ namespace DotnetCounters.UnitTests
             ConsoleWriter exporter = new ConsoleWriter(console);
             exporter.Initialize();
 
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                 Current Value",
                                      "[Provider1]",
                                      "    Counter1 ({widget} / 1 sec)",
                                      "        color=blue                          87",
@@ -239,13 +253,14 @@ namespace DotnetCounters.UnitTests
             ConsoleWriter exporter = new ConsoleWriter(console);
             exporter.Initialize();
 
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue,LongNameTag=ThisDoesNotFit,AnotherOne=Hi", 87), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue,LongNameTag=ThisDoesNotFit,AnotherOne=Hi", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                 Current Value",
                                      "[Provider1]",
                                      "    Counter1 ({widget} / 1 sec)",
                                      "        color=blue,LongNameTag=Thi          87",
@@ -258,17 +273,18 @@ namespace DotnetCounters.UnitTests
         [Fact]
         public void CountersAreTruncatedBeyondScreenHeight()
         {
-            MockConsole console = new MockConsole(50, 6);
+            MockConsole console = new MockConsole(50, 7);
             ConsoleWriter exporter = new ConsoleWriter(console);
             exporter.Initialize();
 
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "",
+                                     "Name                                 Current Value",
                                      "[Provider1]",
                                      "    Counter1 ({widget} / 1 sec)",
                                      "        color=blue                          87");
@@ -281,16 +297,17 @@ namespace DotnetCounters.UnitTests
             ConsoleWriter exporter = new ConsoleWriter(console);
             exporter.Initialize();
 
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "size=1", 14), false);
-            exporter.CounterPayloadReceived(CreateMeterCounter("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
             exporter.SetErrorText("Uh-oh, a bad thing happened");
 
             console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
                                      "    Status: Running",
                                      "Uh-oh, a bad thing happened",
                                      "",
+                                     "Name                                 Current Value",
                                      "[Provider1]",
                                      "    Counter1 ({widget} / 1 sec)",
                                      "        color=blue                          87",
@@ -300,6 +317,118 @@ namespace DotnetCounters.UnitTests
                                      "        temp=hot                           160");
         }
 
+        [Fact]
+        public void DeltaColumnDisplaysInitiallyEmpty()
+        {
+            MockConsole console = new MockConsole(64, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console, showDeltaColumn:true);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "Name                               Current Value      Last Delta",
+                                     "[System.Runtime]",
+                                     "    Allocation Rate (B / 1 sec)        1,731",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                        87",
+                                     "        color=red                          0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                            14",
+                                     "        temp=hot                         160");
+        }
+
+        [Fact]
+        public void DeltaColumnDisplaysNumbersAfterUpdate()
+        {
+            MockConsole console = new MockConsole(64, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console, showDeltaColumn: true);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1731), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 14), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "temp=hot", 160), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "Name                               Current Value      Last Delta",
+                                     "[System.Runtime]",
+                                     "    Allocation Rate (B / 1 sec)        1,731",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                        87",
+                                     "        color=red                          0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                            14",
+                                     "        temp=hot                         160");
+
+            exporter.CounterPayloadReceived(CreateIncrementingEventCounter("System.Runtime", "Allocation Rate", "B", 1732), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=red", 0.2), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPreNet8("Provider1", "Counter2", "{widget}", "size=1", 10), false);
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "Name                               Current Value      Last Delta",
+                                     "[System.Runtime]",
+                                     "    Allocation Rate (B / 1 sec)        1,732               1",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget} / 1 sec)",
+                                     "        color=blue                        87               0",
+                                     "        color=red                          0.2             0.1",
+                                     "    Counter2 ({widget} / 1 sec)",
+                                     "        size=1                            10              -4",
+                                     "        temp=hot                         160");
+        }
+
+        // Starting in .NET 8 MetricsEventSource, Meter counter instruments report both rate of change and
+        // absolute value. Reporting rate in the UI was less useful for many counters than just seeing the raw
+        // value. Now dotnet-counters reports these counters as absolute by default and the optional delta column
+        // is available for folks who still want to visualize rate of change.
+        [Fact]
+        public void MeterCounterIsAbsoluteInNet8()
+        {
+            MockConsole console = new MockConsole(64, 40);
+            ConsoleWriter exporter = new ConsoleWriter(console, showDeltaColumn: true);
+            exporter.Initialize();
+
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", "color=red", 0.1), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter2", "{widget}", "", 14), false);
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "Name                               Current Value      Last Delta",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget})",                                            // There is no longer (unit / 1 sec) here
+                                     "        color=blue                        87",
+                                     "        color=red                          0.1",
+                                     "    Counter2 ({widget})                   14");
+
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", "color=red", 0.2), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter1", "{widget}", "color=blue", 87), false);
+            exporter.CounterPayloadReceived(CreateMeterCounterPostNet8("Provider1", "Counter2", "{widget}", "", 10), false);
+
+            console.AssertLinesEqual("Press p to pause, r to resume, q to quit.",
+                                     "    Status: Running",
+                                     "",
+                                     "Name                               Current Value      Last Delta",
+                                     "[Provider1]",
+                                     "    Counter1 ({widget})",                                            // There is no longer (unit / 1 sec) here
+                                     "        color=blue                        87               0",
+                                     "        color=red                          0.2             0.1",
+                                     "    Counter2 ({widget})                   10              -4");
+        }
 
 
         private static CounterPayload CreateEventCounter(string provider, string displayName, string unit, double value)
@@ -312,9 +441,14 @@ namespace DotnetCounters.UnitTests
             return new EventCounterPayload(DateTime.MinValue, provider, displayName, displayName, unit, value, CounterType.Rate, 0, 1, "");
         }
 
-        private static CounterPayload CreateMeterCounter(string meterName, string instrumentName, string unit, string tags, double value)
+        private static CounterPayload CreateMeterCounterPreNet8(string meterName, string instrumentName, string unit, string tags, double value)
         {
             return new RatePayload(new CounterMetadata(meterName, instrumentName, null, null, null), instrumentName, unit, tags, value, 1, DateTime.MinValue);
         }
+
+        private static CounterPayload CreateMeterCounterPostNet8(string meterName, string instrumentName, string unit, string tags, double value)
+        {
+            return new CounterRateAndValuePayload(new CounterMetadata(meterName, instrumentName, null, null, null), instrumentName, unit, tags, rate:double.NaN, value, DateTime.MinValue);
+        }
     }
 }
index 3660612dcf29cd44b872c246e25da59ac78c0d00..bcb47f9d35540dd78aa696bb815c193d888dd4ce 100644 (file)
@@ -319,6 +319,42 @@ namespace DotnetCounters.UnitTests
                 }
             }
         }
+
+        [Fact]
+        public void CounterReportsAbsoluteValuePostNet8()
+        {
+            // Starting in .NET 8 MetricsEventSource, Meter counter instruments report both rate of change and
+            // absolute value. Reporting rate in the UI was less useful for many counters than just seeing the raw
+            // value. Now dotnet-counters reports these counters as absolute values.
+
+            string fileName = "counterReportsAbsoluteValuePostNet8.json";
+            JSONExporter exporter = new(fileName, "myProcess.exe");
+            exporter.Initialize();
+            DateTime start = DateTime.Now;
+            for (int i = 0; i < 20; i++)
+            {
+                exporter.CounterPayloadReceived(new CounterRateAndValuePayload(new CounterMetadata("myProvider", "heapSize", null, null, null), "Heap Size", "MB", string.Empty, rate: 0, i, start + TimeSpan.FromSeconds(i)), false);
+            }
+            exporter.Stop();
+
+            Assert.True(File.Exists(fileName));
+            using (StreamReader r = new(fileName))
+            {
+                string json = r.ReadToEnd();
+                JSONCounterTrace counterTrace = JsonConvert.DeserializeObject<JSONCounterTrace>(json);
+                Assert.Equal("myProcess.exe", counterTrace.targetProcess);
+                Assert.Equal(20, counterTrace.events.Length);
+                int i = 0;
+                foreach (JSONCounterPayload payload in counterTrace.events)
+                {
+                    Assert.Equal("myProvider", payload.provider);
+                    Assert.Equal("Heap Size (MB)", payload.name);
+                    Assert.Equal("Metric", payload.counterType);
+                    Assert.Equal(i, payload.value);
+                    i += 1;
+                }
+            }
+        }
     }
 
     internal class JSONCounterPayload
index acc5aa14047f6d20cc893b791b28c385cd5bea28..57d41fc13f4df76def0b95f030757a4c05e7ede0 100644 (file)
@@ -114,8 +114,15 @@ namespace DotnetCounters.UnitTests
 
         public void AssertLinesEqual(int startLine, params string[] expectedLines)
         {
-            for(int i = 0; i < expectedLines.Length; i++)
+            if (startLine + expectedLines.Length > Lines.Length)
             {
+                Assert.Fail("MockConsole output had fewer output lines than expected." + Environment.NewLine +
+                    $"Expected line count: {expectedLines.Length}" + Environment.NewLine +
+                    $"Actual line count: {Lines.Length}");
+            }
+            for (int i = 0; i < expectedLines.Length; i++)
+            {
+
                 string actualLine = GetLineText(startLine+i);
                 string expectedLine = expectedLines[i];
                 if(actualLine != expectedLine)