diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj
index eafefa6e9fa33..43fe7a2de45d7 100644
--- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj
+++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj
@@ -126,6 +126,11 @@ System.Diagnostics.DiagnosticSource
+
+
+
+
+
diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/MeterListener.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/MeterListener.cs
index 017074586d583..deef123f52a41 100644
--- a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/MeterListener.cs
+++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/MeterListener.cs
@@ -33,6 +33,14 @@ public sealed class MeterListener : IDisposable
private MeasurementCallback _doubleMeasurementCallback = (instrument, measurement, tags, state) => { /* no-op */ };
private MeasurementCallback _decimalMeasurementCallback = (instrument, measurement, tags, state) => { /* no-op */ };
+ static MeterListener()
+ {
+#if NET9_0_OR_GREATER
+ // This ensures that the static Meter gets created before any listeners exist.
+ _ = RuntimeMetrics.IsEnabled();
+#endif
+ }
+
///
/// Creates a MeterListener object.
///
diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/RuntimeMetrics.cs b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/RuntimeMetrics.cs
new file mode 100644
index 0000000000000..1ac12630a201f
--- /dev/null
+++ b/src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/RuntimeMetrics.cs
@@ -0,0 +1,229 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Threading;
+
+namespace System.Diagnostics.Metrics
+{
+ internal static class RuntimeMetrics
+ {
+ [ThreadStatic] private static bool t_handlingFirstChanceException;
+
+ private const string MeterName = "System.Runtime";
+
+ private static readonly Meter s_meter = new(MeterName);
+
+ // These MUST align to the possible attribute values defined in the semantic conventions (TODO: link to the spec)
+ private static readonly string[] s_genNames = ["gen0", "gen1", "gen2", "loh", "poh"];
+
+ private static readonly int s_maxGenerations = Math.Min(GC.GetGCMemoryInfo().GenerationInfo.Length, s_genNames.Length);
+
+ static RuntimeMetrics()
+ {
+ AppDomain.CurrentDomain.FirstChanceException += (source, e) =>
+ {
+ // Avoid recursion if the listener itself throws an exception while recording the measurement
+ // in its `OnMeasurementRecorded` callback.
+ if (t_handlingFirstChanceException) return;
+ t_handlingFirstChanceException = true;
+ s_exceptions.Add(1, new KeyValuePair("error.type", e.Exception.GetType().Name));
+ t_handlingFirstChanceException = false;
+ };
+ }
+
+ private static readonly ObservableCounter s_gcCollections = s_meter.CreateObservableCounter(
+ "dotnet.gc.collections",
+ GetGarbageCollectionCounts,
+ unit: "{collection}",
+ description: "The number of garbage collections that have occurred since the process has started.");
+
+ private static readonly ObservableUpDownCounter s_processWorkingSet = s_meter.CreateObservableUpDownCounter(
+ "dotnet.process.memory.working_set",
+ () => Environment.WorkingSet,
+ unit: "By",
+ description: "The number of bytes of physical memory mapped to the process context.");
+
+ private static readonly ObservableCounter s_gcHeapTotalAllocated = s_meter.CreateObservableCounter(
+ "dotnet.gc.heap.total_allocated",
+ () => GC.GetTotalAllocatedBytes(),
+ unit: "By",
+ description: "The approximate number of bytes allocated on the managed GC heap since the process has started. The returned value does not include any native allocations.");
+
+ private static readonly ObservableUpDownCounter s_gcLastCollectionMemoryCommitted = s_meter.CreateObservableUpDownCounter(
+ "dotnet.gc.last_collection.memory.committed_size",
+ () =>
+ {
+ GCMemoryInfo gcInfo = GC.GetGCMemoryInfo();
+
+ return gcInfo.Index == 0
+ ? Array.Empty>()
+ : [new(gcInfo.TotalCommittedBytes)];
+ },
+ unit: "By",
+ description: "The amount of committed virtual memory in use by the .NET GC, as observed during the latest garbage collection.");
+
+ private static readonly ObservableUpDownCounter s_gcLastCollectionHeapSize = s_meter.CreateObservableUpDownCounter(
+ "dotnet.gc.last_collection.heap.size",
+ GetHeapSizes,
+ unit: "By",
+ description: "The managed GC heap size (including fragmentation), as observed during the latest garbage collection.");
+
+ private static readonly ObservableUpDownCounter s_gcLastCollectionFragmentationSize = s_meter.CreateObservableUpDownCounter(
+ "dotnet.gc.last_collection.heap.fragmentation.size",
+ GetHeapFragmentation,
+ unit: "By",
+ description: "The heap fragmentation, as observed during the latest garbage collection.");
+
+ private static readonly ObservableCounter s_gcPauseTime = s_meter.CreateObservableCounter(
+ "dotnet.gc.pause.time",
+ () => GC.GetTotalPauseDuration().TotalSeconds,
+ unit: "s",
+ description: "The total amount of time paused in GC since the process has started.");
+
+ private static readonly ObservableCounter s_jitCompiledSize = s_meter.CreateObservableCounter(
+ "dotnet.jit.compiled_il.size",
+ () => Runtime.JitInfo.GetCompiledILBytes(),
+ unit: "By",
+ description: "Count of bytes of intermediate language that have been compiled since the process has started.");
+
+ private static readonly ObservableCounter s_jitCompiledMethodCount = s_meter.CreateObservableCounter(
+ "dotnet.jit.compiled_methods",
+ () => Runtime.JitInfo.GetCompiledMethodCount(),
+ unit: "{method}",
+ description: "The number of times the JIT compiler (re)compiled methods since the process has started.");
+
+ private static readonly ObservableCounter s_jitCompilationTime = s_meter.CreateObservableCounter(
+ "dotnet.jit.compilation.time",
+ () => Runtime.JitInfo.GetCompilationTime().TotalSeconds,
+ unit: "s",
+ description: "The number of times the JIT compiler (re)compiled methods since the process has started.");
+
+ private static readonly ObservableCounter s_monitorLockContention = s_meter.CreateObservableCounter(
+ "dotnet.monitor.lock_contentions",
+ () => Monitor.LockContentionCount,
+ unit: "{contention}",
+ description: "The number of times there was contention when trying to acquire a monitor lock since the process has started.");
+
+ private static readonly ObservableCounter s_threadPoolThreadCount = s_meter.CreateObservableCounter(
+ "dotnet.thread_pool.thread.count",
+ () => (long)ThreadPool.ThreadCount,
+ unit: "{thread}",
+ description: "The number of thread pool threads that currently exist.");
+
+ private static readonly ObservableCounter s_threadPoolCompletedWorkItems = s_meter.CreateObservableCounter(
+ "dotnet.thread_pool.work_item.count",
+ () => ThreadPool.CompletedWorkItemCount,
+ unit: "{work_item}",
+ description: "The number of work items that the thread pool has completed since the process has started.");
+
+ private static readonly ObservableCounter s_threadPoolQueueLength = s_meter.CreateObservableCounter(
+ "dotnet.thread_pool.queue.length",
+ () => ThreadPool.PendingWorkItemCount,
+ unit: "{work_item}",
+ description: "The number of work items that are currently queued to be processed by the thread pool.");
+
+ private static readonly ObservableUpDownCounter s_timerCount = s_meter.CreateObservableUpDownCounter(
+ "dotnet.timer.count",
+ () => Timer.ActiveCount,
+ unit: "{timer}",
+ description: "The number of timer instances that are currently active. An active timer is registered to tick at some point in the future and has not yet been canceled.");
+
+ private static readonly ObservableUpDownCounter s_assembliesCount = s_meter.CreateObservableUpDownCounter(
+ "dotnet.assembly.count",
+ () => (long)AppDomain.CurrentDomain.GetAssemblies().Length,
+ unit: "{assembly}",
+ description: "The number of .NET assemblies that are currently loaded.");
+
+ private static readonly Counter s_exceptions = s_meter.CreateCounter(
+ "dotnet.exceptions",
+ unit: "{exception}",
+ description: "The number of exceptions that have been thrown in managed code.");
+
+ private static readonly ObservableUpDownCounter s_processCpuCount = s_meter.CreateObservableUpDownCounter(
+ "dotnet.process.cpu.count",
+ () => (long)Environment.ProcessorCount,
+ unit: "{cpu}",
+ description: "The number of processors available to the process.");
+
+ // TODO - Uncomment once an implementation for https://github.com/dotnet/runtime/issues/104844 is available.
+ //private static readonly ObservableCounter s_processCpuTime = s_meter.CreateObservableCounter(
+ // "dotnet.process.cpu.time",
+ // GetCpuTime,
+ // unit: "s",
+ // description: "CPU time used by the process as reported by the CLR.");
+
+ public static bool IsEnabled()
+ {
+ return s_gcCollections.Enabled
+ || s_processWorkingSet.Enabled
+ || s_gcHeapTotalAllocated.Enabled
+ || s_gcLastCollectionMemoryCommitted.Enabled
+ || s_gcLastCollectionHeapSize.Enabled
+ || s_gcLastCollectionFragmentationSize.Enabled
+ || s_gcPauseTime.Enabled
+ || s_jitCompiledSize.Enabled
+ || s_jitCompiledMethodCount.Enabled
+ || s_jitCompilationTime.Enabled
+ || s_monitorLockContention.Enabled
+ || s_timerCount.Enabled
+ || s_threadPoolThreadCount.Enabled
+ || s_threadPoolCompletedWorkItems.Enabled
+ || s_threadPoolQueueLength.Enabled
+ || s_assembliesCount.Enabled
+ || s_exceptions.Enabled
+ || s_processCpuCount.Enabled;
+ //|| s_processCpuTime.Enabled;
+ }
+
+ private static IEnumerable> GetGarbageCollectionCounts()
+ {
+ long collectionsFromHigherGeneration = 0;
+
+ for (int gen = GC.MaxGeneration; gen >= 0; --gen)
+ {
+ long collectionsFromThisGeneration = GC.CollectionCount(gen);
+ yield return new(collectionsFromThisGeneration - collectionsFromHigherGeneration, new KeyValuePair("gc.heap.generation", s_genNames[gen]));
+ collectionsFromHigherGeneration = collectionsFromThisGeneration;
+ }
+ }
+
+ // TODO - Uncomment once an implementation for https://github.com/dotnet/runtime/issues/104844 is available.
+ //private static IEnumerable> GetCpuTime()
+ //{
+ // if (OperatingSystem.IsBrowser() || OperatingSystem.IsTvOS() || OperatingSystem.IsIOS())
+ // yield break;
+
+ // ProcessCpuUsage processCpuUsage = Environment.CpuUsage;
+
+ // yield return new(processCpuUsage.UserTime.TotalSeconds, [new KeyValuePair("cpu.mode", "user")]);
+ // yield return new(processCpuUsage.PrivilegedTime.TotalSeconds, [new KeyValuePair("cpu.mode", "system")]);
+ //}
+
+ private static IEnumerable> GetHeapSizes()
+ {
+ GCMemoryInfo gcInfo = GC.GetGCMemoryInfo();
+
+ if (gcInfo.Index == 0)
+ yield break;
+
+ for (int i = 0; i < s_maxGenerations; ++i)
+ {
+ yield return new(gcInfo.GenerationInfo[i].SizeAfterBytes, new KeyValuePair("gc.heap.generation", s_genNames[i]));
+ }
+ }
+
+ private static IEnumerable> GetHeapFragmentation()
+ {
+ GCMemoryInfo gcInfo = GC.GetGCMemoryInfo();
+
+ if (gcInfo.Index == 0)
+ yield break;
+
+ for (int i = 0; i < s_maxGenerations; ++i)
+ {
+ yield return new(gcInfo.GenerationInfo[i].FragmentationAfterBytes, new KeyValuePair("gc.heap.generation", s_genNames[i]));
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/MetricsTests.cs b/src/libraries/System.Diagnostics.DiagnosticSource/tests/MetricsTests.cs
index 3ed6175728250..161c9431df5dd 100644
--- a/src/libraries/System.Diagnostics.DiagnosticSource/tests/MetricsTests.cs
+++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/MetricsTests.cs
@@ -184,7 +184,11 @@ public void ListeningToInstrumentsPublishingTest()
// Listener is not enabled yet
Assert.Equal(0, instrumentsEncountered);
- listener.InstrumentPublished = (instruments, theListener) => instrumentsEncountered++;
+ listener.InstrumentPublished = (theInstrument, theListener) =>
+ {
+ if (theInstrument.Meter.Name == meter.Name)
+ instrumentsEncountered++;
+ };
// Listener still not started yet
Assert.Equal(0, instrumentsEncountered);
@@ -891,7 +895,11 @@ public void MeterDisposalsTest()
Gauge gauge = meter7.CreateGauge("Gauge");
using MeterListener listener = new MeterListener();
- listener.InstrumentPublished = (theInstrument, theListener) => theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ listener.InstrumentPublished = (theInstrument, theListener) =>
+ {
+ if (theInstrument.Meter.Name.StartsWith("MeterDisposalsTest", StringComparison.Ordinal))
+ theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ };
int count = 0;
@@ -970,7 +978,11 @@ public void ListenerDisposalsTest()
int completedMeasurements = 0;
MeterListener listener = new MeterListener();
- listener.InstrumentPublished = (theInstrument, theListener) => theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ listener.InstrumentPublished = (theInstrument, theListener) =>
+ {
+ if (theInstrument.Meter.Name == meter.Name)
+ theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ };
listener.MeasurementsCompleted = (theInstrument, state) => completedMeasurements++;
int count = 0;
@@ -1036,7 +1048,11 @@ public void ListenerWithoutMeasurementsCompletedDisposalsTest()
ObservableUpDownCounter observableUpDownCounter = meter.CreateObservableUpDownCounter("ObservableUpDownCounter", () => new Measurement(-5.7f, new KeyValuePair[] { new KeyValuePair("Key", "value")}));
MeterListener listener = new MeterListener();
- listener.InstrumentPublished = (theInstrument, theListener) => theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ listener.InstrumentPublished = (theInstrument, theListener) =>
+ {
+ if (theInstrument.Meter.Name == meter.Name)
+ theListener.EnableMeasurementEvents(theInstrument, theInstrument);
+ };
int count = 0;
@@ -1178,7 +1194,11 @@ public void EnableListeningMultipleTimesWithDifferentState()
MeterListener listener = new MeterListener();
string lastState = "1";
- listener.InstrumentPublished = (theInstrument, theListener) => theListener.EnableMeasurementEvents(theInstrument, lastState);
+ listener.InstrumentPublished = (theInstrument, theListener) =>
+ {
+ if (theInstrument.Meter.Name == meter.Name)
+ theListener.EnableMeasurementEvents(theInstrument, lastState);
+ };
int completedCount = 0;
listener.MeasurementsCompleted = (theInstrument, state) => { Assert.Equal(lastState, state); completedCount++; };
listener.Start();
diff --git a/src/libraries/System.Diagnostics.DiagnosticSource/tests/RuntimeMetricsTests.cs b/src/libraries/System.Diagnostics.DiagnosticSource/tests/RuntimeMetricsTests.cs
new file mode 100644
index 0000000000000..f5554720f4551
--- /dev/null
+++ b/src/libraries/System.Diagnostics.DiagnosticSource/tests/RuntimeMetricsTests.cs
@@ -0,0 +1,385 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace System.Diagnostics.Metrics.Tests
+{
+ public class RuntimeMetricsTests(ITestOutputHelper output)
+ {
+ private const string GreaterThanZeroMessage = "Expected value to be greater than zero.";
+ private const string GreaterThanOrEqualToZeroMessage = "Expected value to be greater than or equal to zero.";
+
+ private static readonly string[] s_genNames = ["gen0", "gen1", "gen2", "loh", "poh"];
+
+ private static readonly Func s_forceGc = () =>
+ {
+ for (var gen = 0; gen <= GC.MaxGeneration; gen++)
+ {
+ GC.Collect(gen, GCCollectionMode.Forced);
+ }
+
+ return GC.GetGCMemoryInfo().Index > 0;
+ };
+
+ private static readonly Func s_longGreaterThanZero = v => v > 0
+ ? (true, null)
+ : (false, $"{GreaterThanZeroMessage} Actual value was: {v}.");
+
+ private static readonly Func s_longGreaterThanOrEqualToZero = v => v >= 0
+ ? (true, null)
+ : (false, $"{GreaterThanOrEqualToZeroMessage} Actual value was: {v}.");
+
+ private static readonly Func s_doubleGreaterThanZero = v => v > 0
+ ? (true, null)
+ : (false, $"{GreaterThanZeroMessage} Actual value was: {v}.");
+
+ private static readonly Func s_doubleGreaterThanOrEqualToZero = v => v >= 0
+ ? (true, null)
+ : (false, $"{GreaterThanOrEqualToZeroMessage} Actual value was: {v}.");
+
+ private readonly ITestOutputHelper _output = output;
+
+ [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))]
+ public void GcCollectionsCount()
+ {
+ using InstrumentRecorder instrumentRecorder = new("dotnet.gc.collections");
+
+ for (var gen = 0; gen <= GC.MaxGeneration; gen++)
+ {
+ GC.Collect(gen, GCCollectionMode.Forced);
+ }
+
+ instrumentRecorder.RecordObservableInstruments();
+
+ bool[] foundGenerations = new bool[GC.MaxGeneration + 1];
+ for (int i = 0; i < GC.MaxGeneration + 1; i++)
+ {
+ foundGenerations[i] = false;
+ }
+
+ var measurements = instrumentRecorder.GetMeasurements();
+
+ var gensExpected = GC.MaxGeneration + 1;
+ Assert.True(measurements.Count >= gensExpected, $"Expected to find at least one measurement for each generation ({gensExpected}) " +
+ $"but received {measurements.Count} measurements.");
+
+ foreach (Measurement measurement in measurements.Where(m => m.Value >= 1))
+ {
+ var tags = measurement.Tags.ToArray();
+ var tag = tags.SingleOrDefault(k => k.Key == "gc.heap.generation");
+
+ if (tag.Key is not null)
+ {
+ Assert.True(tag.Value is string, "Expected generation tag to be a string.");
+
+ string tagValue = (string)tag.Value;
+
+ switch (tagValue)
+ {
+ case "gen0":
+ foundGenerations[0] = true;
+ break;
+ case "gen1":
+ foundGenerations[1] = true;
+ break;
+ case "gen2":
+ foundGenerations[2] = true;
+ break;
+ default:
+ Assert.Fail($"Unexpected generation tag value '{tagValue}'.");
+ break;
+ }
+ }
+ }
+
+ for (int i = 0; i < foundGenerations.Length; i++)
+ {
+ var generation = i switch
+ {
+ 0 => "gen0",
+ 1 => "gen1",
+ 2 => "gen2",
+ _ => throw new InvalidOperationException("Unexpected generation.")
+ };
+
+ Assert.True(foundGenerations[i], $"Expected to find a measurement for '{generation}'.");
+ }
+ }
+
+ // TODO - Uncomment once an implementation for https://github.com/dotnet/runtime/issues/104844 is available.
+ //[Fact]
+ //public void CpuTime()
+ //{
+ // using InstrumentRecorder instrumentRecorder = new("dotnet.process.cpu.time");
+
+ // instrumentRecorder.RecordObservableInstruments();
+
+ // bool[] foundCpuModes = [false, false];
+
+ // foreach (Measurement measurement in instrumentRecorder.GetMeasurements().Where(m => m.Value >= 0))
+ // {
+ // var tags = measurement.Tags.ToArray();
+ // var tag = tags.SingleOrDefault(k => k.Key == "cpu.mode");
+
+ // if (tag.Key is not null)
+ // {
+ // Assert.True(tag.Value is string, "Expected CPU mode tag to be a string.");
+
+ // string tagValue = (string)tag.Value;
+
+ // switch (tagValue)
+ // {
+ // case "user":
+ // foundCpuModes[0] = true;
+ // break;
+ // case "system":
+ // foundCpuModes[1] = true;
+ // break;
+ // default:
+ // Assert.Fail($"Unexpected CPU mode tag value '{tagValue}'.");
+ // break;
+ // }
+ // }
+ // }
+
+ // for (int i = 0; i < foundCpuModes.Length; i++)
+ // {
+ // var mode = i == 0 ? "user" : "system";
+ // Assert.True(foundCpuModes[i], $"Expected to find a measurement for '{mode}' CPU mode.");
+ // }
+ //}
+
+ [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))]
+ public void ExceptionsCount()
+ {
+ // We inject an exception into the MeterListener callback here, so we can test that we don't recursively record exceptions.
+ using InstrumentRecorder instrumentRecorder = new("dotnet.exceptions", injectException: true);
+
+ try
+ {
+ throw new RuntimeMeterException();
+ }
+ catch
+ {
+ // Ignore the exception.
+ }
+
+ var measurements = instrumentRecorder.GetMeasurements();
+
+ AssertExceptions(measurements, 1);
+
+ try
+ {
+ throw new RuntimeMeterException();
+ }
+ catch
+ {
+ // Ignore the exception.
+ }
+
+ measurements = instrumentRecorder.GetMeasurements();
+
+ AssertExceptions(measurements, 2);
+
+ static void AssertExceptions(IReadOnlyList> measurements, int expectedCount)
+ {
+ int foundExpectedExceptions = 0;
+ int foundUnexpectedExceptions = 0;
+
+ foreach (Measurement measurement in measurements)
+ {
+ var tags = measurement.Tags.ToArray();
+ var tag = tags.Single(k => k.Key == "error.type");
+
+ Assert.NotNull(tag.Key);
+ Assert.NotNull(tag.Value);
+
+ if (tag.Value is not string tagValue)
+ {
+ Assert.Fail("Expected error type tag to be a string.");
+ return;
+ }
+
+ if (tagValue == nameof(RuntimeMeterException))
+ {
+ foundExpectedExceptions++;
+ }
+ else if (tagValue == nameof(InstrumentRecorderException))
+ {
+ foundUnexpectedExceptions++;
+ }
+ }
+
+ Assert.Equal(expectedCount, foundExpectedExceptions);
+ Assert.Equal(0, foundUnexpectedExceptions);
+ }
+ }
+
+ public static IEnumerable
+
+
+