|  | 
|  | 1 | +// Licensed to the .NET Foundation under one or more agreements. | 
|  | 2 | +// The .NET Foundation licenses this file to you under the MIT license. | 
|  | 3 | + | 
|  | 4 | +using System; | 
|  | 5 | +using System.Collections.Generic; | 
|  | 6 | +using System.Diagnostics; | 
|  | 7 | +using System.Diagnostics.Metrics; | 
|  | 8 | +using System.Linq; | 
|  | 9 | +using Microsoft.Extensions.Logging; | 
|  | 10 | +using Microsoft.Extensions.Logging.Abstractions; | 
|  | 11 | +using Microsoft.Extensions.Options; | 
|  | 12 | +using Microsoft.Shared.Instruments; | 
|  | 13 | + | 
|  | 14 | +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Disk; | 
|  | 15 | + | 
|  | 16 | +internal sealed class LinuxSystemDiskMetrics | 
|  | 17 | +{ | 
|  | 18 | +    // The kernel's block layer always reports counts in 512-byte "sectors" regardless of the underlying device's real block size | 
|  | 19 | +    // https://docs.kernel.org/block/stat.html#read-sectors-write-sectors-discard-sectors | 
|  | 20 | +    private const int LinuxDiskSectorSize = 512; | 
|  | 21 | +    private const int MinimumDiskStatsRefreshIntervalInSeconds = 10; | 
|  | 22 | +    private const string DeviceKey = "system.device"; | 
|  | 23 | +    private const string DirectionKey = "disk.io.direction"; | 
|  | 24 | + | 
|  | 25 | +    private static readonly KeyValuePair<string, object?> _directionReadTag = new(DirectionKey, "read"); | 
|  | 26 | +    private static readonly KeyValuePair<string, object?> _directionWriteTag = new(DirectionKey, "write"); | 
|  | 27 | +    private readonly ILogger<LinuxSystemDiskMetrics> _logger; | 
|  | 28 | +    private readonly TimeProvider _timeProvider; | 
|  | 29 | +    private readonly IDiskStatsReader _diskStatsReader; | 
|  | 30 | +    private readonly object _lock = new(); | 
|  | 31 | +    private readonly Dictionary<string, DiskStats> _baselineDiskStatsDict = []; | 
|  | 32 | +    private List<DiskStats> _diskStatsSnapshot = []; | 
|  | 33 | +    private DateTimeOffset _lastRefreshTime = DateTimeOffset.MinValue; | 
|  | 34 | + | 
|  | 35 | +    public LinuxSystemDiskMetrics( | 
|  | 36 | +        ILogger<LinuxSystemDiskMetrics>? logger, | 
|  | 37 | +        IMeterFactory meterFactory, | 
|  | 38 | +        IOptions<ResourceMonitoringOptions> options, | 
|  | 39 | +        TimeProvider timeProvider, | 
|  | 40 | +        IDiskStatsReader diskStatsReader) | 
|  | 41 | +    { | 
|  | 42 | +        _logger = logger ?? NullLogger<LinuxSystemDiskMetrics>.Instance; | 
|  | 43 | +        _timeProvider = timeProvider; | 
|  | 44 | +        _diskStatsReader = diskStatsReader; | 
|  | 45 | +        if (!options.Value.EnableSystemDiskIoMetrics) | 
|  | 46 | +        { | 
|  | 47 | +            return; | 
|  | 48 | +        } | 
|  | 49 | + | 
|  | 50 | +        // We need to read the disk stats once to get the baseline values | 
|  | 51 | +        _baselineDiskStatsDict = GetAllDiskStats().ToDictionary(d => d.DeviceName); | 
|  | 52 | + | 
|  | 53 | +#pragma warning disable CA2000 // Dispose objects before losing scope | 
|  | 54 | +        // We don't dispose the meter because IMeterFactory handles that | 
|  | 55 | +        // It's a false-positive, see: https://github.com/dotnet/roslyn-analyzers/issues/6912. | 
|  | 56 | +        // Related documentation: https://github.com/dotnet/docs/pull/37170 | 
|  | 57 | +        Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); | 
|  | 58 | +#pragma warning restore CA2000 // Dispose objects before losing scope | 
|  | 59 | + | 
|  | 60 | +        // The metric is aligned with | 
|  | 61 | +        // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio | 
|  | 62 | +        _ = meter.CreateObservableCounter( | 
|  | 63 | +            ResourceUtilizationInstruments.SystemDiskIo, | 
|  | 64 | +            GetDiskIoMeasurements, | 
|  | 65 | +            unit: "By", | 
|  | 66 | +            description: "Disk bytes transferred"); | 
|  | 67 | + | 
|  | 68 | +        // The metric is aligned with | 
|  | 69 | +        // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskoperations | 
|  | 70 | +        _ = meter.CreateObservableCounter( | 
|  | 71 | +            ResourceUtilizationInstruments.SystemDiskOperations, | 
|  | 72 | +            GetDiskOperationMeasurements, | 
|  | 73 | +            unit: "{operation}", | 
|  | 74 | +            description: "Disk operations"); | 
|  | 75 | + | 
|  | 76 | +        // The metric is aligned with | 
|  | 77 | +        // https://opentelemetry.io/docs/specs/semconv/system/system-metrics/#metric-systemdiskio_time | 
|  | 78 | +        _ = meter.CreateObservableCounter( | 
|  | 79 | +            ResourceUtilizationInstruments.SystemDiskIoTime, | 
|  | 80 | +            GetDiskIoTimeMeasurements, | 
|  | 81 | +            unit: "s", | 
|  | 82 | +            description: "Time disk spent activated"); | 
|  | 83 | +    } | 
|  | 84 | + | 
|  | 85 | +    private IEnumerable<Measurement<long>> GetDiskIoMeasurements() | 
|  | 86 | +    { | 
|  | 87 | +        List<Measurement<long>> measurements = []; | 
|  | 88 | +        List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | 
|  | 89 | + | 
|  | 90 | +        foreach (DiskStats diskStats in diskStatsSnapshot) | 
|  | 91 | +        { | 
|  | 92 | +            _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); | 
|  | 93 | +            long readBytes = (long)(diskStats.SectorsRead - baselineDiskStats?.SectorsRead ?? 0L) * LinuxDiskSectorSize; | 
|  | 94 | +            long writeBytes = (long)(diskStats.SectorsWritten - baselineDiskStats?.SectorsWritten ?? 0L) * LinuxDiskSectorSize; | 
|  | 95 | +            measurements.Add(new Measurement<long>(readBytes, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); | 
|  | 96 | +            measurements.Add(new Measurement<long>(writeBytes, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); | 
|  | 97 | +        } | 
|  | 98 | + | 
|  | 99 | +        return measurements; | 
|  | 100 | +    } | 
|  | 101 | + | 
|  | 102 | +    private IEnumerable<Measurement<long>> GetDiskOperationMeasurements() | 
|  | 103 | +    { | 
|  | 104 | +        List<Measurement<long>> measurements = []; | 
|  | 105 | +        List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | 
|  | 106 | + | 
|  | 107 | +        foreach (DiskStats diskStats in diskStatsSnapshot) | 
|  | 108 | +        { | 
|  | 109 | +            _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); | 
|  | 110 | +            long readCount = (long)(diskStats.ReadsCompleted - baselineDiskStats?.ReadsCompleted ?? 0L); | 
|  | 111 | +            long writeCount = (long)(diskStats.WritesCompleted - baselineDiskStats?.WritesCompleted ?? 0L); | 
|  | 112 | +            measurements.Add(new Measurement<long>(readCount, new TagList { _directionReadTag, new(DeviceKey, diskStats.DeviceName) })); | 
|  | 113 | +            measurements.Add(new Measurement<long>(writeCount, new TagList { _directionWriteTag, new(DeviceKey, diskStats.DeviceName) })); | 
|  | 114 | +        } | 
|  | 115 | + | 
|  | 116 | +        return measurements; | 
|  | 117 | +    } | 
|  | 118 | + | 
|  | 119 | +    private IEnumerable<Measurement<double>> GetDiskIoTimeMeasurements() | 
|  | 120 | +    { | 
|  | 121 | +        List<Measurement<double>> measurements = []; | 
|  | 122 | +        List<DiskStats> diskStatsSnapshot = GetDiskStatsSnapshot(); | 
|  | 123 | + | 
|  | 124 | +        foreach (DiskStats diskStats in diskStatsSnapshot) | 
|  | 125 | +        { | 
|  | 126 | +            _ = _baselineDiskStatsDict.TryGetValue(diskStats.DeviceName, out DiskStats? baselineDiskStats); | 
|  | 127 | +            double ioTimeSeconds = (diskStats.TimeIoMs - baselineDiskStats?.TimeIoMs ?? 0) / 1000.0; // Convert to seconds | 
|  | 128 | +            measurements.Add(new Measurement<double>(ioTimeSeconds, new TagList { new(DeviceKey, diskStats.DeviceName) })); | 
|  | 129 | +        } | 
|  | 130 | + | 
|  | 131 | +        return measurements; | 
|  | 132 | +    } | 
|  | 133 | + | 
|  | 134 | +    private List<DiskStats> GetDiskStatsSnapshot() | 
|  | 135 | +    { | 
|  | 136 | +        lock (_lock) | 
|  | 137 | +        { | 
|  | 138 | +            DateTimeOffset now = _timeProvider.GetUtcNow(); | 
|  | 139 | +            if (_diskStatsSnapshot.Count == 0 || (now - _lastRefreshTime).TotalSeconds > MinimumDiskStatsRefreshIntervalInSeconds) | 
|  | 140 | +            { | 
|  | 141 | +                _diskStatsSnapshot = GetAllDiskStats(); | 
|  | 142 | +                _lastRefreshTime = now; | 
|  | 143 | +            } | 
|  | 144 | +        } | 
|  | 145 | + | 
|  | 146 | +        return _diskStatsSnapshot; | 
|  | 147 | +    } | 
|  | 148 | + | 
|  | 149 | +    private List<DiskStats> GetAllDiskStats() | 
|  | 150 | +    { | 
|  | 151 | +        try | 
|  | 152 | +        { | 
|  | 153 | +            List<DiskStats> diskStatsList = _diskStatsReader.ReadAll(); | 
|  | 154 | + | 
|  | 155 | +            // We should not include ram, loop, or dm(device-mapper) devices in the disk stats, should we? | 
|  | 156 | +            diskStatsList = diskStatsList | 
|  | 157 | +                .Where(d => !d.DeviceName.StartsWith("ram", StringComparison.OrdinalIgnoreCase) | 
|  | 158 | +                            && !d.DeviceName.StartsWith("loop", StringComparison.OrdinalIgnoreCase) | 
|  | 159 | +                            && !d.DeviceName.StartsWith("dm-", StringComparison.OrdinalIgnoreCase)) | 
|  | 160 | +                .ToList(); | 
|  | 161 | +            return diskStatsList; | 
|  | 162 | +        } | 
|  | 163 | +#pragma warning disable CA1031 | 
|  | 164 | +        catch (Exception ex) | 
|  | 165 | +#pragma warning restore CA1031 | 
|  | 166 | +        { | 
|  | 167 | +            Log.HandleDiskStatsException(_logger, ex.Message); | 
|  | 168 | +        } | 
|  | 169 | + | 
|  | 170 | +        return []; | 
|  | 171 | +    } | 
|  | 172 | +} | 
0 commit comments