From 3a913c437504cbbd2237d8092f9300a66c57a0bc Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 25 Aug 2026 12:10:49 +0200 Subject: [PATCH] feat(core): add Prometheus metrics provider and node mapper --- .../Prometheus/PrometheusMetricsProvider.cs | 32 +++++++ .../Prometheus/PrometheusNodeMapper.cs | 92 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/Vantage.Core/Prometheus/PrometheusMetricsProvider.cs create mode 100644 src/Vantage.Core/Prometheus/PrometheusNodeMapper.cs diff --git a/src/Vantage.Core/Prometheus/PrometheusMetricsProvider.cs b/src/Vantage.Core/Prometheus/PrometheusMetricsProvider.cs new file mode 100644 index 0000000..7b349e4 --- /dev/null +++ b/src/Vantage.Core/Prometheus/PrometheusMetricsProvider.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using Vantage.Core.Metrics; +using Vantage.Core.Models; + +namespace Vantage.Core.Prometheus; + +public sealed class PrometheusMetricsProvider : IMetricsProvider +{ + private readonly HttpClient httpClient; + private readonly Func clock; + private readonly ConcurrentDictionary cpuState = new(); + + public PrometheusMetricsProvider(HttpClient httpClient, Func? clock = null) + { + this.httpClient = httpClient; + this.clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public async Task GetMetricsAsync(HostConfig host, CancellationToken cancellationToken) + { + var endpoint = host.ResolveMetricsEndpoint(); + var exposition = await httpClient.GetStringAsync(endpoint, cancellationToken).ConfigureAwait(false); + var samples = PrometheusTextParser.Parse(exposition); + + var current = PrometheusNodeMapper.ExtractCpuCounters(samples); + var hadPrevious = cpuState.TryGetValue(host.Id, out var previous); + cpuState[host.Id] = current; + var cpuPercent = hadPrevious ? PrometheusNodeMapper.CpuPercent(previous, current) : 0; + + return PrometheusNodeMapper.Map(samples, host.DiskMount, cpuPercent, clock()); + } +} diff --git a/src/Vantage.Core/Prometheus/PrometheusNodeMapper.cs b/src/Vantage.Core/Prometheus/PrometheusNodeMapper.cs new file mode 100644 index 0000000..c33228f --- /dev/null +++ b/src/Vantage.Core/Prometheus/PrometheusNodeMapper.cs @@ -0,0 +1,92 @@ +using Vantage.Core.Models; + +namespace Vantage.Core.Prometheus; + +public readonly record struct CpuCounters(double IdleSeconds, double TotalSeconds); + +public static class PrometheusNodeMapper +{ + public static CpuCounters ExtractCpuCounters(IReadOnlyList samples) + { + double idle = 0; + double total = 0; + + foreach (var sample in samples) + { + if (sample.Name != "node_cpu_seconds_total") + continue; + + total += sample.Value; + if (sample.HasLabel("mode", "idle")) + idle += sample.Value; + } + + return new CpuCounters(idle, total); + } + + public static double CpuPercent(CpuCounters previous, CpuCounters current) + { + var totalDelta = current.TotalSeconds - previous.TotalSeconds; + var idleDelta = current.IdleSeconds - previous.IdleSeconds; + if (totalDelta <= 0) + return 0; + + var busy = (totalDelta - idleDelta) / totalDelta * 100.0; + return Math.Clamp(Math.Round(busy, 1), 0, 100); + } + + public static HostMetrics Map( + IReadOnlyList samples, + string diskMount, + double cpuPercent, + DateTimeOffset now) + { + var memTotal = (long)First(samples, "node_memory_MemTotal_bytes"); + var memAvailable = (long)First(samples, "node_memory_MemAvailable_bytes"); + var memUsed = Math.Max(0, memTotal - memAvailable); + + var diskSize = (long)FirstForMount(samples, "node_filesystem_size_bytes", diskMount); + var diskAvail = (long)FirstForMount(samples, "node_filesystem_avail_bytes", diskMount); + var diskUsed = Math.Max(0, diskSize - diskAvail); + + var uptimeSeconds = First(samples, "node_time_seconds") - First(samples, "node_boot_time_seconds"); + var uptime = uptimeSeconds > 0 ? TimeSpan.FromSeconds(uptimeSeconds) : TimeSpan.Zero; + + return new HostMetrics + { + CpuUsagePercent = cpuPercent, + MemoryUsedBytes = memUsed, + MemoryTotalBytes = memTotal, + DiskUsedBytes = diskUsed, + DiskTotalBytes = diskSize, + Load1 = First(samples, "node_load1"), + Load5 = First(samples, "node_load5"), + Load15 = First(samples, "node_load15"), + Uptime = uptime, + CollectedAt = now + }; + } + + private static double First(IReadOnlyList samples, string name) + { + foreach (var sample in samples) + if (sample.Name == name) + return sample.Value; + return 0; + } + + private static double FirstForMount(IReadOnlyList samples, string name, string mount) + { + double? fallback = null; + foreach (var sample in samples) + { + if (sample.Name != name) + continue; + if (sample.HasLabel("mountpoint", mount)) + return sample.Value; + fallback ??= sample.Value; + } + + return fallback ?? 0; + } +}