-
Notifications
You must be signed in to change notification settings - Fork 832
Implement disk io metrics for linux #6374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
amadeuszl
merged 21 commits into
dotnet:main
from
makazeu:ImplementDiskIoMetricsForLinux
Jun 2, 2025
Merged
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
830f3e9
Implement DiskStatsReader
makazeu 18288a0
Add a UT
makazeu d727f7c
Merge branch 'main' into ImplementDiskIoMetricsForLinux
makazeu 9dfd1ab
Add LinuxDiskMetrics
makazeu b4bfd43
Add DiskOperation and DiskIoTime metrics for Linux
makazeu a179ef3
update
makazeu 1fb860b
Merge branch 'main' into ImplementDiskIoMetricsForLinux
makazeu 39c9c70
Add more tests
makazeu 37b3a20
Add UT
makazeu 097cba4
Added more tests
makazeu 8322d41
Merge branch 'main' into ImplementDiskIoMetricsForLinux
makazeu e60a07c
Merge branch 'refs/heads/main' into ImplementDiskIoMetricsForLinux
makazeu 4e67000
Update src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitor…
makazeu 846ecc3
Change ulong properties to uint in DiskStats and DiskStatsReader for …
makazeu c56fd4b
Merge remote-tracking branch 'origin/ImplementDiskIoMetricsForLinux' …
makazeu 555b327
update
makazeu a972b87
Merge branch 'refs/heads/main' into ImplementDiskIoMetricsForLinux
makazeu b61db2f
Rename `EnableDiskIoMetrics` to `EnableSystemDiskIoMetrics`
makazeu 7e112fb
Add compatibility suppressions for EnableDiskIoMetrics
makazeu f7063cf
Add compatibility suppressions for EnableDiskIoMetrics
makazeu 633fb1c
Merge branch 'main' into ImplementDiskIoMetricsForLinux
makazeu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
36 changes: 36 additions & 0 deletions
36
src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStats.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; | ||
|
||
/// <summary> | ||
/// Represents one line of statistics from "/proc/diskstats" | ||
/// See https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats for details. | ||
/// </summary> | ||
internal sealed class DiskStats | ||
{ | ||
public int MajorNumber { get; set; } | ||
public int MinorNumber { get; set; } | ||
public string DeviceName { get; set; } = string.Empty; | ||
public ulong ReadsCompleted { get; set; } | ||
public ulong ReadsMerged { get; set; } | ||
public ulong SectorsRead { get; set; } | ||
public ulong TimeReadingMs { get; set; } | ||
public ulong WritesCompleted { get; set; } | ||
public ulong WritesMerged { get; set; } | ||
public ulong SectorsWritten { get; set; } | ||
public ulong TimeWritingMs { get; set; } | ||
public ulong IoInProgress { get; set; } | ||
public ulong TimeIoMs { get; set; } | ||
public ulong WeightedTimeIoMs { get; set; } | ||
|
||
// The following fields are available starting from kernel 4.18; if absent, remain 0 | ||
public ulong DiscardsCompleted { get; set; } | ||
public ulong DiscardsMerged { get; set; } | ||
public ulong SectorsDiscarded { get; set; } | ||
public ulong TimeDiscardingMs { get; set; } | ||
|
||
// The following fields are available starting from kernel 5.5; if absent, remain 0 | ||
public ulong FlushRequestsCompleted { get; set; } | ||
public ulong TimeFlushingMs { get; set; } | ||
} |
111 changes: 111 additions & 0 deletions
111
...braries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
// 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.CodeAnalysis; | ||
using System.Globalization; | ||
using System.IO; | ||
using Microsoft.Extensions.ObjectPool; | ||
using Microsoft.Shared.Pools; | ||
|
||
namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; | ||
|
||
/// <summary> | ||
/// Handles reading and parsing of Linux procfs-diskstats file(/proc/diskstats). | ||
/// </summary> | ||
internal sealed class DiskStatsReader(IFileSystem fileSystem) : IDiskStatsReader | ||
{ | ||
private static readonly FileInfo _diskStatsFile = new("/proc/diskstats"); | ||
private static readonly ObjectPool<BufferWriter<char>> _sharedBufferWriterPool = BufferWriterPool.CreateBufferWriterPool<char>(); | ||
|
||
/// <summary> | ||
/// Reads and returns all disk statistics entries. | ||
/// </summary> | ||
/// <returns>List of <see cref="DiskStats"/>.</returns> | ||
public List<DiskStats> ReadAll() | ||
{ | ||
var diskStatsList = new List<DiskStats>(); | ||
|
||
using ReturnableBufferWriter<char> bufferWriter = new(_sharedBufferWriterPool); | ||
using IEnumerator<ReadOnlyMemory<char>> enumerableLines = fileSystem.ReadAllByLines(_diskStatsFile, bufferWriter.Buffer).GetEnumerator(); | ||
|
||
while (enumerableLines.MoveNext()) | ||
{ | ||
string line = enumerableLines.Current.Trim().ToString(); | ||
if (string.IsNullOrWhiteSpace(line)) | ||
{ | ||
continue; | ||
} | ||
|
||
try | ||
{ | ||
DiskStats stat = DiskStatsReader.ParseLine(line); | ||
diskStatsList.Add(stat); | ||
} | ||
#pragma warning disable CA1031 | ||
catch (Exception) | ||
#pragma warning restore CA1031 | ||
{ | ||
// ignore parsing errors | ||
} | ||
} | ||
|
||
return diskStatsList; | ||
} | ||
|
||
/// <summary> | ||
/// Parses one line of text into a DiskStats object. | ||
/// </summary> | ||
/// <param name="line">one line in "/proc/diskstats".</param> | ||
/// <returns>parsed DiskStats object.</returns> | ||
[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "These numbers represent fixed field indices in the Linux /proc/diskstats format")] | ||
private static DiskStats ParseLine(string line) | ||
{ | ||
// Split by any whitespace and remove empty entries | ||
#pragma warning disable EA0009 | ||
string[] parts = line.Split(Array.Empty<char>(), StringSplitOptions.RemoveEmptyEntries); | ||
#pragma warning restore EA0009 | ||
|
||
if (parts.Length < 14) | ||
{ | ||
throw new FormatException($"Not enough fields: expected at least 14, got {parts.Length}"); | ||
} | ||
|
||
// See https://www.kernel.org/doc/Documentation/ABI/testing/procfs-diskstats | ||
var diskStats = new DiskStats | ||
{ | ||
MajorNumber = int.Parse(parts[0], CultureInfo.InvariantCulture), | ||
MinorNumber = int.Parse(parts[1], CultureInfo.InvariantCulture), | ||
DeviceName = parts[2], | ||
ReadsCompleted = ulong.Parse(parts[3], CultureInfo.InvariantCulture), | ||
ReadsMerged = ulong.Parse(parts[4], CultureInfo.InvariantCulture), | ||
SectorsRead = ulong.Parse(parts[5], CultureInfo.InvariantCulture), | ||
TimeReadingMs = ulong.Parse(parts[6], CultureInfo.InvariantCulture), | ||
makazeu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
WritesCompleted = ulong.Parse(parts[7], CultureInfo.InvariantCulture), | ||
WritesMerged = ulong.Parse(parts[8], CultureInfo.InvariantCulture), | ||
SectorsWritten = ulong.Parse(parts[9], CultureInfo.InvariantCulture), | ||
TimeWritingMs = ulong.Parse(parts[10], CultureInfo.InvariantCulture), | ||
IoInProgress = ulong.Parse(parts[11], CultureInfo.InvariantCulture), | ||
TimeIoMs = ulong.Parse(parts[12], CultureInfo.InvariantCulture), | ||
WeightedTimeIoMs = ulong.Parse(parts[13], CultureInfo.InvariantCulture) | ||
}; | ||
|
||
// Parse additional fields if present | ||
if (parts.Length >= 18) | ||
{ | ||
diskStats.DiscardsCompleted = ulong.Parse(parts[14], CultureInfo.InvariantCulture); | ||
diskStats.DiscardsMerged = ulong.Parse(parts[15], CultureInfo.InvariantCulture); | ||
diskStats.SectorsDiscarded = ulong.Parse(parts[16], CultureInfo.InvariantCulture); | ||
diskStats.TimeDiscardingMs = ulong.Parse(parts[17], CultureInfo.InvariantCulture); | ||
} | ||
|
||
if (parts.Length >= 20) | ||
{ | ||
diskStats.FlushRequestsCompleted = ulong.Parse(parts[18], CultureInfo.InvariantCulture); | ||
diskStats.TimeFlushingMs = ulong.Parse(parts[19], CultureInfo.InvariantCulture); | ||
} | ||
|
||
return diskStats; | ||
} | ||
} |
18 changes: 18 additions & 0 deletions
18
...raries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/IDiskStatsReader.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
// 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; | ||
|
||
namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; | ||
|
||
/// <summary> | ||
/// An interface for reading disk statistics. | ||
/// </summary> | ||
internal interface IDiskStatsReader | ||
{ | ||
/// <summary> | ||
/// Gets all the disk statistics from the system. | ||
/// </summary> | ||
/// <returns>List of <see cref="DiskStats"/> instances.</returns> | ||
List<DiskStats> ReadAll(); | ||
} |
184 changes: 184 additions & 0 deletions
184
...raries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxDiskMetrics.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
// 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; | ||
using System.Diagnostics.Metrics; | ||
using System.Linq; | ||
using Microsoft.Extensions.Logging; | ||
using Microsoft.Extensions.Logging.Abstractions; | ||
using Microsoft.Extensions.Options; | ||
using Microsoft.Shared.Instruments; | ||
|
||
namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; | ||
|
||
internal sealed class LinuxDiskMetrics | ||
{ | ||
// The kernel's block layer always reports counts in 512-byte "sectors" regardless of the underlying device's real block size | ||
// https://docs.kernel.org/block/stat.html#read-sectors-write-sectors-discard-sectors | ||
private const int LinuxDiskSectorSize = 512; | ||
private const int MinimumDiskStatsRefreshIntervalInSeconds = 10; | ||
private const string DeviceKey = "system.device"; | ||
private const string DirectionKey = "disk.io.direction"; | ||
|
||
private static readonly KeyValuePair<string, object?> _directionReadTag = new(DirectionKey, "read"); | ||
private static readonly KeyValuePair<string, object?> _directionWriteTag = new(DirectionKey, "write"); | ||
private readonly ILogger<LinuxDiskMetrics> _logger; | ||
private readonly TimeProvider _timeProvider; | ||
private readonly IDiskStatsReader _diskStatsReader; | ||
private readonly object _lock = new(); | ||
private readonly Dictionary<string, DiskStats> _baselineDiskStatsDict = []; | ||
private List<DiskStats> _diskStatsSnapshot = []; | ||
private DateTimeOffset _lastRefreshTime = DateTimeOffset.MinValue; | ||
|
||
public LinuxDiskMetrics( | ||
ILogger<LinuxDiskMetrics>? logger, | ||
IMeterFactory meterFactory, | ||
IOptions<ResourceMonitoringOptions> options, | ||
TimeProvider timeProvider, | ||
IDiskStatsReader diskStatsReader) | ||
{ | ||
_logger = logger ?? NullLogger<LinuxDiskMetrics>.Instance; | ||
_timeProvider = timeProvider; | ||
_diskStatsReader = diskStatsReader; | ||
if (!options.Value.EnableDiskIoMetrics) | ||
{ | ||
return; | ||
} | ||
|
||
// We need to read the disk stats once to get the baseline values | ||
_baselineDiskStatsDict = GetAllDiskStats().ToDictionary(d => d.DeviceName); | ||
|
||
#pragma warning disable CA2000 // Dispose objects before losing scope | ||
// We don't dispose the meter because IMeterFactory handles that | ||
// It's a false-positive, see: https://github.com/dotnet/roslyn-analyzers/issues/6912. | ||
// Related documentation: https://github.com/dotnet/docs/pull/37170 | ||
Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); | ||
#pragma warning restore CA2000 // Dispose objects before losing scope | ||
|
||
// The metric is aligned with | ||
// https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio | ||
_ = meter.CreateObservableCounter( | ||
ResourceUtilizationInstruments.SystemDiskIo, | ||
GetDiskIoMeasurements, | ||
unit: "By", | ||
description: "Disk bytes transferred"); | ||
|
||
// The metric is aligned with | ||
// https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskoperations | ||
_ = meter.CreateObservableCounter( | ||
ResourceUtilizationInstruments.SystemDiskOperations, | ||
GetDiskOperationMeasurements, | ||
unit: "{operation}", | ||
description: "Disk operations"); | ||
|
||
// The metric is aligned with | ||
// https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio_time | ||
_ = meter.CreateObservableCounter( | ||
ResourceUtilizationInstruments.SystemDiskIoTime, | ||
GetDiskIoTimeMeasurements, | ||
unit: "s", | ||
description: "Time disk spent activated"); | ||
} | ||
|
||
private IEnumerable<Measurement<long>> GetDiskIoMeasurements() | ||
{ | ||
List<Measurement<long>> measurements = []; | ||
List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | ||
|
||
foreach (DiskStats diskStats in diskStatsSnapshot) | ||
{ | ||
if (!_baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats)) | ||
{ | ||
continue; | ||
} | ||
|
||
long readBytes = (long)(diskStats.SectorsRead - baselineDiskStats.SectorsRead) * LinuxDiskSectorSize; | ||
long writeBytes = (long)(diskStats.SectorsWritten - baselineDiskStats.SectorsWritten) * LinuxDiskSectorSize; | ||
measurements.Add(new Measurement<long>(readBytes, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); | ||
measurements.Add(new Measurement<long>(writeBytes, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); | ||
} | ||
|
||
return measurements; | ||
} | ||
|
||
private IEnumerable<Measurement<long>> GetDiskOperationMeasurements() | ||
{ | ||
List<Measurement<long>> measurements = []; | ||
List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | ||
|
||
foreach (DiskStats diskStats in diskStatsSnapshot) | ||
{ | ||
if (!_baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats)) | ||
{ | ||
continue; | ||
} | ||
|
||
long readCount = (long)(diskStats.ReadsCompleted - baselineDiskStats.ReadsCompleted); | ||
long writeCount = (long)(diskStats.WritesCompleted - baselineDiskStats.WritesCompleted); | ||
measurements.Add(new Measurement<long>(readCount, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); | ||
measurements.Add(new Measurement<long>(writeCount, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); | ||
} | ||
|
||
return measurements; | ||
} | ||
|
||
private IEnumerable<Measurement<double>> GetDiskIoTimeMeasurements() | ||
{ | ||
List<Measurement<double>> measurements = []; | ||
List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | ||
|
||
foreach (DiskStats diskStats in diskStatsSnapshot) | ||
{ | ||
if (!_baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats)) | ||
makazeu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
{ | ||
continue; | ||
} | ||
|
||
double ioTimeSeconds = (diskStats.TimeIoMs - baselineDiskStats.TimeIoMs) / 1000.0; // Convert to seconds | ||
measurements.Add(new Measurement<double>(ioTimeSeconds, new TagList { new(DeviceKey, diskStats.DeviceName) })); | ||
} | ||
|
||
return measurements; | ||
} | ||
|
||
private List<DiskStats> GetDiskStatsSnapshot() | ||
{ | ||
lock (_lock) | ||
{ | ||
DateTimeOffset now = _timeProvider.GetUtcNow(); | ||
if (_diskStatsSnapshot.Count == 0 || (now - _lastRefreshTime).TotalSeconds > MinimumDiskStatsRefreshIntervalInSeconds) | ||
{ | ||
_diskStatsSnapshot = GetAllDiskStats(); | ||
_lastRefreshTime = now; | ||
} | ||
} | ||
|
||
return _diskStatsSnapshot; | ||
} | ||
|
||
private List<DiskStats> GetAllDiskStats() | ||
{ | ||
try | ||
{ | ||
List<DiskStats> diskStatsList = _diskStatsReader.ReadAll(); | ||
|
||
// We should not include ram, loop, or dm(device-mapper) devices in the disk stats, should we? | ||
diskStatsList = diskStatsList | ||
.Where(d => !d.DeviceName.StartsWith("ram", StringComparison.OrdinalIgnoreCase) | ||
&& !d.DeviceName.StartsWith("loop", StringComparison.OrdinalIgnoreCase) | ||
&& !d.DeviceName.StartsWith("dm-", StringComparison.OrdinalIgnoreCase)) | ||
.ToList(); | ||
return diskStatsList; | ||
} | ||
#pragma warning disable CA1031 | ||
catch (Exception ex) | ||
#pragma warning restore CA1031 | ||
{ | ||
Log.HandleDiskStatsException(_logger, ex.Message); | ||
} | ||
|
||
return []; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.