Dotnet Counters + Dotnet Monitor Unification - Automated `Collect` Testing (#4253)
authorkkeirstead <85592574+kkeirstead@users.noreply.github.com>
Wed, 11 Oct 2023 14:10:55 +0000 (07:10 -0700)
committerGitHub <noreply@github.com>
Wed, 11 Oct 2023 14:10:55 +0000 (07:10 -0700)
src/tests/CommonTestRunner/TestRunnerUtilities.cs [new file with mode: 0644]
src/tests/EventPipeTracee/CustomMetrics.cs [new file with mode: 0644]
src/tests/EventPipeTracee/EventPipeTracee.csproj
src/tests/EventPipeTracee/Program.cs
src/tests/Microsoft.Diagnostics.Monitoring.EventPipe/PipelineTestUtilities.cs
src/tests/dotnet-counters/CSVExporterTests.cs
src/tests/dotnet-counters/CounterMonitorPayloadTests.cs [new file with mode: 0644]
src/tests/dotnet-counters/DotnetCounters.UnitTests.csproj
src/tests/dotnet-counters/TestConstants.cs [new file with mode: 0644]

diff --git a/src/tests/CommonTestRunner/TestRunnerUtilities.cs b/src/tests/CommonTestRunner/TestRunnerUtilities.cs
new file mode 100644 (file)
index 0000000..7cbc88b
--- /dev/null
@@ -0,0 +1,59 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Diagnostics.TestHelpers;
+using Xunit.Abstractions;
+using TestRunner = Microsoft.Diagnostics.CommonTestRunner.TestRunner;
+
+namespace CommonTestRunner
+{
+    public static class TestRunnerUtilities
+    {
+        public static async Task<TestRunner> StartProcess(TestConfiguration config, string testArguments, ITestOutputHelper outputHelper, int testProcessTimeout = 60_000)
+        {
+            TestRunner runner = await TestRunner.Create(config, outputHelper, "EventPipeTracee", testArguments).ConfigureAwait(true);
+            await runner.Start(testProcessTimeout).ConfigureAwait(true);
+            return runner;
+        }
+
+        public static async Task ExecuteCollection(
+            Func<CancellationToken, Task> executeCollection,
+            TestRunner testRunner,
+            CancellationToken token)
+        {
+            Task collectionTask = executeCollection(token);
+            await ExecuteCollection(collectionTask, testRunner, token).ConfigureAwait(false);
+        }
+
+        public static async Task ExecuteCollection(
+            Task collectionTask,
+            TestRunner testRunner,
+            CancellationToken token,
+            Func<CancellationToken, Task> waitForPipeline = null)
+        {
+            // Begin event production
+            testRunner.WakeupTracee();
+
+            // Wait for event production to be done
+            testRunner.WaitForSignal();
+
+            try
+            {
+                if (waitForPipeline != null)
+                {
+                    await waitForPipeline(token).ConfigureAwait(false);
+                }
+
+                await collectionTask.ConfigureAwait(true);
+            }
+            finally
+            {
+                // Signal for debuggee that it's ok to end/move on.
+                testRunner.WakeupTracee();
+            }
+        }
+    }
+}
diff --git a/src/tests/EventPipeTracee/CustomMetrics.cs b/src/tests/EventPipeTracee/CustomMetrics.cs
new file mode 100644 (file)
index 0000000..d16b036
--- /dev/null
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Metrics;
+using Constants = DotnetCounters.UnitTests.TestConstants;
+
+namespace EventPipeTracee
+{
+    internal sealed class CustomMetrics : IDisposable
+    {
+        private Meter _meter;
+        private Counter<int> _counter;
+        private Histogram<float> _histogram;
+
+        public CustomMetrics()
+        {
+            _meter = new(Constants.TestMeterName);
+            _counter = _meter.CreateCounter<int>(Constants.TestCounter, "dollars");
+            _histogram = _meter.CreateHistogram<float>(Constants.TestHistogram, "feet");
+            // consider adding gauge/etc. here
+        }
+
+        public void IncrementCounter(int v = 1)
+        {
+            _counter.Add(v);
+        }
+
+        public void RecordHistogram(float v = 10.0f)
+        {
+            KeyValuePair<string, object> tags = new(Constants.TagKey, Constants.TagValue);
+            _histogram.Record(v, tags);
+        }
+
+        public void Dispose() => _meter?.Dispose();
+    }
+}
index b94bb2b3cd6a32420b84ae4a8466550c9ad97e2f..f61b62cedadc7cdd44ddb70b156f228b8f14cb75 100644 (file)
@@ -1,10 +1,14 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <OutputType>Exe</OutputType>
     <TargetFramework Condition="'$(BuildProjectFramework)' != ''">$(BuildProjectFramework)</TargetFramework>
     <TargetFrameworks Condition="'$(BuildProjectFramework)' == ''">net6.0;net7.0;net8.0</TargetFrameworks>
   </PropertyGroup>
 
+  <ItemGroup>
+    <Compile Include="../dotnet-counters/TestConstants.cs" Link="TestConstants.cs" />
+  </ItemGroup>
+  
   <ItemGroup>
     <PackageReference Include="Microsoft.Extensions.Logging.EventSource" Version="$(MicrosoftExtensionsLoggingEventSourceVersion)" />
   </ItemGroup>
index 0ede793dffdfaa565f094440fc399a44b8ec4425..0d379b97d71e3a88d5a5bf809cc8cba1e9e5fac4 100644 (file)
@@ -6,6 +6,8 @@ using System.Collections.Generic;
 using System.Diagnostics;
 using System.IO.Pipes;
 using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 
@@ -29,6 +31,10 @@ namespace EventPipeTracee
             bool spinWait10 = args.Length > 2 && "SpinWait10".Equals(args[2], StringComparison.Ordinal);
             string loggerCategory = args[1];
 
+            bool diagMetrics = args.Any("DiagMetrics".Equals);
+
+            Console.WriteLine($"{pid} EventPipeTracee: DiagMetrics {diagMetrics}");
+
             Console.WriteLine($"{pid} EventPipeTracee: start process");
             Console.Out.Flush();
 
@@ -54,12 +60,35 @@ namespace EventPipeTracee
             Console.WriteLine($"{pid} EventPipeTracee: {DateTime.UtcNow} Awaiting start");
             Console.Out.Flush();
 
+            using CustomMetrics metrics = diagMetrics ? new CustomMetrics() : null;
+
             // Wait for server to send something
             int input = pipeStream.ReadByte();
 
             Console.WriteLine($"{pid} {DateTime.UtcNow} Starting test body '{input}'");
             Console.Out.Flush();
 
+            CancellationTokenSource recordMetricsCancellationTokenSource = new();
+
+            if (diagMetrics)
+            {
+                _ = Task.Run(async () => {
+
+                    // Recording a single value appeared to cause test flakiness due to a race
+                    // condition with the timing of when dotnet-counters starts collecting and
+                    // when these values are published. Publishing values repeatedly bypasses this problem.
+                    while (!recordMetricsCancellationTokenSource.Token.IsCancellationRequested)
+                    {
+                        recordMetricsCancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+                        metrics.IncrementCounter();
+                        metrics.RecordHistogram(10.0f);
+                        await Task.Delay(1000).ConfigureAwait(true);
+                    }
+
+                }).ConfigureAwait(true);
+            }
+
             TestBodyCore(customCategoryLogger, appCategoryLogger);
 
             Console.WriteLine($"{pid} EventPipeTracee: signal end of test data");
@@ -87,6 +116,8 @@ namespace EventPipeTracee
             // Wait for server to send something
             input = pipeStream.ReadByte();
 
+            recordMetricsCancellationTokenSource.Cancel();
+
             Console.WriteLine($"{pid} EventPipeTracee {DateTime.UtcNow} Ending remote test process '{input}'");
             return 0;
         }
index 70f72ecb4921803f1eabc5452247615e60296d05..ffd52880e48927cbbd7959fde39c8ab0f91c737b 100644 (file)
@@ -4,6 +4,7 @@
 using System;
 using System.Threading;
 using System.Threading.Tasks;
+using CommonTestRunner;
 using Microsoft.Diagnostics.TestHelpers;
 using Xunit.Abstractions;
 using TestRunner = Microsoft.Diagnostics.CommonTestRunner.TestRunner;
@@ -16,9 +17,7 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests
 
         public static async Task<TestRunner> StartProcess(TestConfiguration config, string testArguments, ITestOutputHelper outputHelper, int testProcessTimeout = 60_000)
         {
-            TestRunner runner = await TestRunner.Create(config, outputHelper, "EventPipeTracee", testArguments);
-            await runner.Start(testProcessTimeout);
-            return runner;
+            return await TestRunnerUtilities.StartProcess(config, testArguments, outputHelper, testProcessTimeout);
         }
 
         public static async Task ExecutePipelineWithTracee(
@@ -75,14 +74,7 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests
         {
             Task runTask = await startPipelineAsync(pipeline, token);
 
-            // Begin event production
-            testRunner.WakeupTracee();
-
-            // Wait for event production to be done
-            testRunner.WaitForSignal();
-
-            try
-            {
+            Func<CancellationToken, Task> waitForPipeline = async (cancellationToken) => {
                 // Optionally wait on caller before allowing the pipeline to stop.
                 if (null != waitTaskSource)
                 {
@@ -96,15 +88,9 @@ namespace Microsoft.Diagnostics.Monitoring.EventPipe.UnitTests
 
                 //Signal for the pipeline to stop
                 await pipeline.StopAsync(token);
+            };
 
-                //After a pipeline is stopped, we should expect the RunTask to eventually finish
-                await runTask;
-            }
-            finally
-            {
-                // Signal for debugee that's ok to end/move on.
-                testRunner.WakeupTracee();
-            }
+            await TestRunnerUtilities.ExecuteCollection(runTask, testRunner, token, waitForPipeline);
         }
     }
 }
index fca3d71c758500df0af7a9dd3e38467b5c2e2249..7c283ff4e344284d1080700842dc156b7fb084de 100644 (file)
@@ -36,11 +36,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(101, lines.Count); // should be 101 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -78,12 +74,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(11, lines.Count); // should be 11 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
-
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -121,11 +112,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(101, lines.Count); // should be 101 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -163,11 +150,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(101, lines.Count); // should be 101 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -205,11 +188,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(101, lines.Count); // should be 101 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -247,11 +226,7 @@ namespace DotnetCounters.UnitTests
                 List<string> lines = File.ReadLines(fileName).ToList();
                 Assert.Equal(101, lines.Count); // should be 101 including the headers
 
-                string[] headerTokens = lines[0].Split(',');
-                Assert.Equal("Provider", headerTokens[1]);
-                Assert.Equal("Counter Name", headerTokens[2]);
-                Assert.Equal("Counter Type", headerTokens[3]);
-                Assert.Equal("Mean/Increment", headerTokens[4]);
+                ValidateHeaderTokens(lines[0]);
 
                 for (int i = 1; i < lines.Count; i++)
                 {
@@ -268,5 +243,14 @@ namespace DotnetCounters.UnitTests
                 File.Delete(fileName);
             }
         }
+
+        internal static void ValidateHeaderTokens(string headerLine)
+        {
+            string[] headerTokens = headerLine.Split(',');
+            Assert.Equal("Provider", headerTokens[TestConstants.ProviderIndex]);
+            Assert.Equal("Counter Name", headerTokens[TestConstants.CounterNameIndex]);
+            Assert.Equal("Counter Type", headerTokens[TestConstants.CounterTypeIndex]);
+            Assert.Equal("Mean/Increment", headerTokens[TestConstants.ValueIndex]);
+        }
     }
 }
diff --git a/src/tests/dotnet-counters/CounterMonitorPayloadTests.cs b/src/tests/dotnet-counters/CounterMonitorPayloadTests.cs
new file mode 100644 (file)
index 0000000..b2c38cb
--- /dev/null
@@ -0,0 +1,336 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.CommandLine.IO;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using CommonTestRunner;
+using Microsoft.Diagnostics.TestHelpers;
+using Microsoft.Diagnostics.Tools.Counters;
+using Xunit;
+using Xunit.Abstractions;
+using Xunit.Extensions;
+using TestRunner = Microsoft.Diagnostics.CommonTestRunner.TestRunner;
+using Constants = DotnetCounters.UnitTests.TestConstants;
+
+namespace DotnetCounters.UnitTests
+{
+    /// <summary>
+    /// Tests the behavior of CounterMonitor's Collect command.
+    /// </summary>
+    public class CounterMonitorPayloadTests
+    {
+        private enum CounterTypes
+        {
+            Metric, Rate
+        }
+
+        private ITestOutputHelper _outputHelper;
+        private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(2);
+        private static readonly string SystemRuntimeName = "System.Runtime";
+        private static readonly string TagStart = "[";
+
+        private static HashSet<CounterTypes> ExpectedCounterTypes = new() { CounterTypes.Metric, CounterTypes.Rate };
+
+        public CounterMonitorPayloadTests(ITestOutputHelper outputHelper)
+        {
+            _outputHelper = outputHelper;
+        }
+
+        [SkippableTheory, MemberData(nameof(Configurations))]
+        public async Task TestCounterMonitorCustomMetricsJSON(TestConfiguration configuration)
+        {
+            CheckRuntimeOS();
+            CheckFramework(configuration);
+
+            List<MetricComponents> metricComponents = await GetCounterTraceJSON(configuration, new List<string> { Constants.TestMeterName });
+
+            ValidateCustomMetrics(metricComponents, CountersExportFormat.json);
+        }
+
+        [SkippableTheory, MemberData(nameof(Configurations))]
+        public async Task TestCounterMonitorCustomMetricsCSV(TestConfiguration configuration)
+        {
+            CheckRuntimeOS();
+            CheckFramework(configuration);
+
+            List<MetricComponents> metricComponents = await GetCounterTraceCSV(configuration, new List<string> { Constants.TestMeterName });
+
+            ValidateCustomMetrics(metricComponents, CountersExportFormat.csv);
+        }
+
+        [SkippableTheory, MemberData(nameof(Configurations))]
+        public async Task TestCounterMonitorSystemRuntimeMetricsJSON(TestConfiguration configuration)
+        {
+            CheckRuntimeOS();
+
+            List<MetricComponents> metricComponents = await GetCounterTraceJSON(configuration, new List<string> { SystemRuntimeName });
+
+            ValidateSystemRuntimeMetrics(metricComponents);
+        }
+
+        [SkippableTheory, MemberData(nameof(Configurations))]
+        public async Task TestCounterMonitorSystemRuntimeMetricsCSV(TestConfiguration configuration)
+        {
+            CheckRuntimeOS();
+
+            List<MetricComponents> metricComponents = await GetCounterTraceCSV(configuration, new List<string> { SystemRuntimeName });
+
+            ValidateSystemRuntimeMetrics(metricComponents);
+        }
+
+        private void ValidateSystemRuntimeMetrics(List<MetricComponents> metricComponents)
+        {
+            string[] ExpectedProviders = { "System.Runtime" };
+            Assert.Equal(ExpectedProviders, metricComponents.Select(c => c.ProviderName).ToHashSet());
+
+            // Could also just check the number of counter names
+            HashSet<string> expectedCounterNames = new()
+            {
+                "CPU Usage (%)",
+                "Working Set (MB)",
+                "GC Heap Size (MB)",
+                "Gen 0 GC Count (Count / 1 sec)",
+                "Gen 1 GC Count (Count / 1 sec)",
+                "Gen 2 GC Count (Count / 1 sec)",
+                "ThreadPool Thread Count",
+                "Monitor Lock Contention Count (Count / 1 sec)",
+                "ThreadPool Queue Length",
+                "ThreadPool Completed Work Item Count (Count / 1 sec)",
+                "Allocation Rate (B / 1 sec)",
+                "Number of Active Timers",
+                "GC Fragmentation (%)",
+                "GC Committed Bytes (MB)",
+                "Exception Count (Count / 1 sec)",
+                "% Time in GC since last GC (%)",
+                "Gen 0 Size (B)",
+                "Gen 1 Size (B)",
+                "Gen 2 Size (B)",
+                "LOH Size (B)",
+                "POH (Pinned Object Heap) Size (B)",
+                "Number of Assemblies Loaded",
+                "IL Bytes Jitted (B)",
+                "Number of Methods Jitted",
+                "Time spent in JIT (ms / 1 sec)"
+            };
+
+            Assert.Subset(metricComponents.Select(c => c.CounterName).ToHashSet(), expectedCounterNames);
+
+            Assert.Equal(ExpectedCounterTypes, metricComponents.Select(c => c.CounterType).ToHashSet());
+        }
+
+        private async Task<List<MetricComponents>> GetCounterTraceJSON(TestConfiguration configuration, List<string> counterList)
+        {
+            string path = Path.ChangeExtension(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()), "json");
+
+            Func<List<MetricComponents>> createMetricComponents = () =>
+            {
+                using FileStream metricsFile = File.OpenRead(path);
+                JSONCounterTrace trace = JsonSerializer.Deserialize<JSONCounterTrace>(metricsFile, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+                var providers = trace.events.Select(e => e.provider).ToList();
+                var counterNames = trace.events.Select(e => e.name).ToList();
+                var counterTypes = trace.events.Select(e => e.counterType).ToList();
+                var tags = trace.events.Select(e => e.tags).ToList();
+                var values = trace.events.Select(e => e.value).ToList();
+
+                return CreateMetricComponents(providers, counterNames, counterTypes, tags, values);
+            };
+
+            return await GetCounterTrace(configuration, counterList, path, CountersExportFormat.json, createMetricComponents);
+        }
+
+        private async Task<List<MetricComponents>> GetCounterTraceCSV(TestConfiguration configuration, List<string> counterList)
+        {
+            string path = Path.ChangeExtension(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()), "csv");
+
+            Func<List<MetricComponents>> createMetricComponents = () =>
+            {
+                List<string> lines = File.ReadLines(path).ToList();
+                CSVExporterTests.ValidateHeaderTokens(lines[0]);
+                lines.RemoveAt(0); // Trim the header
+
+                IEnumerable<string[]> splitLines = lines.Select(l => l.Split(","));
+
+                var providers = splitLines.Select(line => line[Constants.ProviderIndex]).ToList();
+                var countersList = splitLines.Select(line => line[Constants.CounterNameIndex]).ToList();
+                var counterNames = countersList.Select(counter => counter.Split(TagStart)[0]).ToList();
+                var counterTypes = splitLines.Select(line => line[Constants.CounterTypeIndex]).ToList();
+                var tags = GetCSVTags(countersList);
+                var values = GetCSVValues(splitLines);
+
+                return CreateMetricComponents(providers, counterNames, counterTypes, tags, values);
+            };
+
+            return await GetCounterTrace(configuration, counterList, path, CountersExportFormat.csv, createMetricComponents);
+        }
+
+        private List<MetricComponents> CreateMetricComponents(List<string> providers, List<string> counterNames, List<string> counterTypes, List<string> tags, List<double> values)
+        {
+            List<MetricComponents> metricComponents = new(providers.Count());
+
+            for (int index = 0; index < providers.Count(); ++index)
+            {
+                CounterTypes type;
+                Enum.TryParse(counterTypes[index], out type);
+
+                metricComponents.Add(new MetricComponents()
+                {
+                    ProviderName = providers[index],
+                    CounterName = counterNames[index],
+                    CounterType = type,
+                    Tags = tags[index],
+                    Value = values[index]
+                });
+            }
+
+            return metricComponents;
+        }
+
+        private async Task<List<MetricComponents>> GetCounterTrace(TestConfiguration configuration, List<string> counterList, string path, CountersExportFormat exportFormat, Func<List<MetricComponents>> CreateMetricComponents)
+        {
+            try
+            {
+                CounterMonitor monitor = new CounterMonitor();
+
+                using CancellationTokenSource source = new CancellationTokenSource(DefaultTimeout);
+
+                await using var testRunner = await TestRunnerUtilities.StartProcess(configuration, "TestCounterMonitor DiagMetrics", _outputHelper);
+
+                await TestRunnerUtilities.ExecuteCollection((ct) => {
+                    return Task.Run(async () =>
+                        await monitor.Collect(
+                            ct: ct,
+                            counter_list: counterList,
+                            counters: null,
+                            console: new TestConsole(),
+                            processId: testRunner.Pid,
+                            refreshInterval: 1,
+                            format: exportFormat,
+                            output: path,
+                            name: null,
+                            diagnosticPort: null,
+                            resumeRuntime: false,
+                            maxHistograms: 10,
+                            maxTimeSeries: 1000,
+                            duration: TimeSpan.FromSeconds(10)));
+                }, testRunner, source.Token);
+
+                return CreateMetricComponents();
+            }
+            finally
+            {
+                try
+                {
+                    File.Delete(path);
+                }
+                catch { }
+            }
+        }
+
+        private void ValidateCustomMetrics(List<MetricComponents> metricComponents, CountersExportFormat format)
+        {
+            // Currently not validating timestamp due to https://github.com/dotnet/diagnostics/issues/3905
+
+            HashSet<string> expectedProviders = new() { Constants.TestMeterName };
+            Assert.Equal(expectedProviders, metricComponents.Select(c => c.ProviderName).ToHashSet());
+
+            HashSet<string> expectedCounterNames = new() { Constants.TestHistogramName, Constants.TestCounterName };
+            Assert.Equal(expectedCounterNames, metricComponents.Select(c => c.CounterName).ToHashSet());
+
+            Assert.Equal(ExpectedCounterTypes, metricComponents.Select(c => c.CounterType).ToHashSet());
+
+            string tagSeparator = format == CountersExportFormat.csv ? ";" : ",";
+            string tag = Constants.TagKey + "=" + Constants.TagValue + tagSeparator + Constants.PercentileKey + "=";
+            HashSet<string> expectedTags = new() { $"{tag}{Constants.Quantile50}", $"{tag}{Constants.Quantile95}", $"{tag}{Constants.Quantile99}" };
+            Assert.Equal(expectedTags, metricComponents.Where(c => c.CounterName == Constants.TestHistogramName).Select(c => c.Tags).Distinct());
+            Assert.Empty(metricComponents.Where(c => c.CounterName == Constants.TestCounterName).Where(c => c.Tags != string.Empty));
+
+            var actualCounterValues = metricComponents.Where(c => c.CounterName == Constants.TestCounterName).Select(c => c.Value);
+
+            Assert.NotEmpty(actualCounterValues);
+            double histogramValue = Assert.Single(metricComponents.Where(c => c.CounterName == Constants.TestHistogramName).Select(c => c.Value).Distinct());
+            Assert.Equal(10, histogramValue);
+        }
+
+        private List<string> GetCSVTags(List<string> countersList)
+        {
+            var tags = countersList.Select(counter => {
+                var split = counter.Split(TagStart);
+                return split.Length > 1 ? split[1].Remove(split[1].Length - 1) : string.Empty;
+            }).ToList();
+
+            return tags;
+        }
+
+        private List<double> GetCSVValues(IEnumerable<string[]> splitLines)
+        {
+            return splitLines.Select(line => {
+                return double.TryParse(line[Constants.ValueIndex], out double val) ? val : -1;
+            }).ToList();
+        }
+
+        private void CheckFramework(TestConfiguration configuration)
+        {
+            if (configuration.RuntimeFrameworkVersionMajor < 8)
+            {
+                throw new SkipTestException("Not supported on < .NET 8.0");
+            }
+        }
+
+        private void CheckRuntimeOS()
+        {
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+            {
+                throw new SkipTestException("Test instability on OSX");
+            }
+        }
+
+        public static IEnumerable<object[]> Configurations => TestRunner.Configurations;
+
+        private sealed class MetricComponents
+        {
+            public string ProviderName { get; set; }
+            public string CounterName { get; set; }
+            public double Value { get; set; }
+            public string Tags { get; set; }
+            public CounterTypes CounterType { get; set; }
+        }
+
+        private sealed class TestConsole : IConsole
+        {
+            private readonly TestStandardStreamWriter _outWriter;
+            private readonly TestStandardStreamWriter _errorWriter;
+
+            private sealed class TestStandardStreamWriter : IStandardStreamWriter
+            {
+                private StringWriter _writer = new();
+                public void Write(string value) => _writer.Write(value);
+                public void WriteLine(string value) => _writer.WriteLine(value);
+            }
+
+            public TestConsole()
+            {
+                _outWriter = new TestStandardStreamWriter();
+                _errorWriter = new TestStandardStreamWriter();
+            }
+
+            public IStandardStreamWriter Out => _outWriter;
+
+            public bool IsOutputRedirected => true;
+
+            public IStandardStreamWriter Error => _errorWriter;
+
+            public bool IsErrorRedirected => true;
+
+            public bool IsInputRedirected => false;
+        }
+    }
+}
index 06c187814133393ef3387898d49e67ca0b684d60..00f6e91c0d90e96272b05b2d0f14d8ef6f76ede6 100644 (file)
@@ -6,6 +6,8 @@
 
   <ItemGroup>
     <ProjectReference Include="../../Tools/dotnet-counters/dotnet-counters.csproj" />
+    <ProjectReference Include="../../Microsoft.Diagnostics.TestHelpers/Microsoft.Diagnostics.TestHelpers.csproj" />
+    <ProjectReference Include="../CommonTestRunner/CommonTestRunner.csproj" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/src/tests/dotnet-counters/TestConstants.cs b/src/tests/dotnet-counters/TestConstants.cs
new file mode 100644 (file)
index 0000000..4e9912d
--- /dev/null
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace DotnetCounters.UnitTests
+{
+    public static class TestConstants
+    {
+        public const string TestCounter = "TestCounter";
+        public const string TestCounterName = TestCounter + " (dollars / 1 sec)";
+        public const string TestHistogram = "TestHistogram";
+        public const string TestHistogramName = TestHistogram + " (feet)";
+        public const string PercentileKey = "Percentile";
+        public const string TagKey = "TestTag";
+        public const string TagValue = "5";
+        public const string TestMeterName = "TestMeter";
+        public const string Quantile50 = "50";
+        public const string Quantile95 = "95";
+        public const string Quantile99 = "99";
+
+        public const int ProviderIndex = 1;
+        public const int CounterNameIndex = 2;
+        public const int CounterTypeIndex = 3;
+        public const int ValueIndex = 4;
+    }
+}