Initial commit: JMon cross-platform monitoring system

- gRPC client-server metrics collection
- PeriodicTimer-based 5-second sampling
- Delta encoding with configurable thresholds
- Queue/retry mechanism for resilience
- ProcessId tracking for duplicate detection
- Server-side state reconstruction
- Native AOT compilation support
- Docker/Podman containerization
- PostgreSQL + TimescaleDB persistence layer
This commit is contained in:
2026-01-05 23:23:55 -06:00
commit 127b3ad5bf
16 changed files with 1241 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-JMonAgent-7f8df008-660b-45a2-b649-64956a63f63c</UserSecretsId>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<OptimizationPreference>Size</OptimizationPreference>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.27.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Grpc.Tools" Version="2.65.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="../Protos/metrics.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup Condition="'$(RuntimeIdentifier)' != '' AND $(RuntimeIdentifier.StartsWith('win'))">
<PackageReference Include="System.Management" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,286 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace JMonAgent;
public interface IMetricCollector
{
SystemMetrics Collect();
}
public class LinuxMetricCollector : IMetricCollector
{
private long _prevJiffy;
private long _prevIdle;
private string? _cachedIpAddress;
private string? _cachedHwId;
public SystemMetrics Collect()
{
var memInfo = ParseMemInfo();
var swapInfo = ParseSwapInfo();
var diskInfo = ParseDiskInfo();
// Cache IP and HWID on first call
_cachedIpAddress ??= GetIpAddress();
_cachedHwId ??= GetHwId();
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
GetCpuUsage(),
memInfo.UsedBytes,
memInfo.TotalBytes,
swapInfo.UsedBytes,
swapInfo.TotalBytes,
diskInfo.UsedBytes,
diskInfo.TotalBytes,
diskInfo.MountPoint,
DateTime.UtcNow,
_cachedIpAddress ?? "",
_cachedHwId ?? "",
Environment.ProcessId
);
}
private string GetIpAddress()
{
try
{
var psi = new ProcessStartInfo("hostname", "-I")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return "";
var output = process.StandardOutput.ReadToEnd().Trim();
var addresses = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return addresses.FirstOrDefault() ?? "";
}
catch
{
return "";
}
}
private string GetHwId()
{
try
{
// Try to read machine-id from Linux
if (File.Exists("/etc/machine-id"))
{
return File.ReadAllText("/etc/machine-id").Trim();
}
// Fallback: use MAC address from first interface
var interfaces = Directory.GetFiles("/sys/class/net/");
if (interfaces.Length > 0)
{
var firstInterface = Path.GetFileName(interfaces[0]);
var macPath = $"/sys/class/net/{firstInterface}/address";
if (File.Exists(macPath))
{
return File.ReadAllText(macPath).Trim();
}
}
return "";
}
catch
{
return "";
}
}
private (long UsedBytes, long TotalBytes) ParseMemInfo()
{
try
{
var lines = File.ReadAllLines("/proc/meminfo");
var dict = new Dictionary<string, long>();
foreach (var line in lines)
{
var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value))
{
dict[parts[0]] = value * 1024; // Convert from KB to bytes
}
}
long total = dict.TryGetValue("MemTotal", out var t) ? t : 0;
long available = dict.TryGetValue("MemAvailable", out var a) ? a : 0;
long used = total - available;
return (used, total);
}
catch
{
return (0, 0);
}
}
private (long UsedBytes, long TotalBytes) ParseSwapInfo()
{
try
{
var lines = File.ReadAllLines("/proc/meminfo");
var dict = new Dictionary<string, long>();
foreach (var line in lines)
{
var parts = line.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && long.TryParse(Regex.Replace(parts[1], @"\D+", ""), out var value))
{
dict[parts[0]] = value * 1024; // Convert from KB to bytes
}
}
long total = dict.TryGetValue("SwapTotal", out var st) ? st : 0;
long free = dict.TryGetValue("SwapFree", out var sf) ? sf : 0;
long used = total - free;
return (used, total);
}
catch
{
return (0, 0);
}
}
private (long UsedBytes, long TotalBytes, string MountPoint) ParseDiskInfo()
{
try
{
// Use 'df' command to get root filesystem info
var psi = new ProcessStartInfo("df", "-B1 /")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return (0, 0, "/");
var output = process.StandardOutput.ReadToEnd();
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Skip header, parse second line
if (lines.Length >= 2)
{
var parts = lines[1].Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 4
&& long.TryParse(parts[1], out var total)
&& long.TryParse(parts[2], out var used))
{
return (used, total, parts[parts.Length - 1]);
}
}
return (0, 0, "/");
}
catch
{
return (0, 0, "/");
}
}
private double GetCpuUsage()
{
try
{
var firstLine = File.ReadLines("/proc/stat").First();
var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 5) return 0.0;
// /proc/stat format: cpu user nice system idle iowait irq softirq steal guest guest_nice
long user = long.Parse(parts[1]);
long nice = long.Parse(parts[2]);
long system = long.Parse(parts[3]);
long idle = long.Parse(parts[4]);
long totalJiffy = user + nice + system + idle;
long activeJiffy = user + nice + system;
// First call - initialize baseline
if (_prevJiffy == 0)
{
_prevJiffy = totalJiffy;
_prevIdle = idle;
return 0.0;
}
// Calculate deltas
long diffTotal = totalJiffy - _prevJiffy;
long diffIdle = idle - _prevIdle;
_prevJiffy = totalJiffy;
_prevIdle = idle;
if (diffTotal == 0) return 0.0;
double usage = 100.0 * (1.0 - ((double)diffIdle / diffTotal));
return Math.Max(0, Math.Min(100, usage));
}
catch
{
return 0.0;
}
}
}
public class WindowsMetricCollector : IMetricCollector
{
public SystemMetrics Collect()
{
// TODO: Use PerformanceCounters or WMI for Windows
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
0.0,
0,
0,
0,
0,
0,
0,
"C:\\",
DateTime.UtcNow,
"",
"",
Environment.ProcessId
);
}
}
public class MacMetricCollector : IMetricCollector
{
public SystemMetrics Collect()
{
// TODO: Use sysctl for macOS metrics
return new SystemMetrics(
Environment.MachineName,
RuntimeInformation.OSDescription,
0.0,
0,
0,
0,
0,
0,
0,
"/",
DateTime.UtcNow,
"",
"",
Environment.ProcessId
);
}
}

32
JMonAgent/MetricsQueue.cs Normal file
View File

@@ -0,0 +1,32 @@
using System.Collections.Concurrent;
namespace JMonAgent;
public class MetricsQueue
{
private readonly ConcurrentQueue<JMon.Protos.DeltaMetrics> _queue = new();
private readonly ILogger<MetricsQueue> _logger;
public MetricsQueue(ILogger<MetricsQueue> logger)
{
_logger = logger;
}
public void Enqueue(JMon.Protos.DeltaMetrics metrics)
{
_queue.Enqueue(metrics);
_logger.LogWarning("Metrics queued (total: {count})", _queue.Count);
}
public List<JMon.Protos.DeltaMetrics> DequeueAll()
{
var items = new List<JMon.Protos.DeltaMetrics>();
while (_queue.TryDequeue(out var item))
{
items.Add(item);
}
return items;
}
public int Count => _queue.Count;
}

8
JMonAgent/Program.cs Normal file
View File

@@ -0,0 +1,8 @@
using JMonAgent;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<MetricsQueue>();
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"JMonAgent": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace JMonAgent;
public record SystemMetrics(
string MachineName,
string OSDescription,
double CpuUsagePercentage,
long MemoryUsedBytes,
long MemoryTotalBytes,
long SwapUsedBytes,
long SwapTotalBytes,
long DiskUsedBytes,
long DiskTotalBytes,
string DiskMountPoint,
DateTime Timestamp,
string IpAddress,
string HwId,
int ProcessId
);
public record DeltaMetrics(
string MachineName,
string? OSDescription,
double? CpuUsagePercentage,
long? MemoryUsedBytes,
long? MemoryTotalBytes,
long? SwapUsedBytes,
long? SwapTotalBytes,
long? DiskUsedBytes,
long? DiskTotalBytes,
string? DiskMountPoint,
DateTime Timestamp,
string? IpAddress,
string? HwId,
int ProcessId
);
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SystemMetrics))]
[JsonSerializable(typeof(DeltaMetrics))]
internal partial class MetricsJsonContext : JsonSerializerContext
{
}

308
JMonAgent/Worker.cs Normal file
View File

@@ -0,0 +1,308 @@
using System.Runtime.InteropServices;
using Grpc.Net.Client;
using JMon.Protos;
namespace JMonAgent;
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IMetricCollector _collector;
private readonly MetricsQueue _queue;
private readonly bool _debugMode;
private GrpcChannel? _channel;
private MetricsService.MetricsServiceClient? _client;
private SystemMetrics? _previousMetrics;
private readonly SemaphoreSlim _executionLock = new(1, 1);
private const double CpuThreshold = 0.5; // Only send if change > 0.5%
private const long MemoryThreshold = 10 * 1024 * 1024; // 10 MB
private const long SwapThreshold = 10 * 1024 * 1024; // 10 MB
private const long DiskThreshold = 100 * 1024 * 1024; // 100 MB
public Worker(ILogger<Worker> logger, MetricsQueue queue, IConfiguration config)
{
_logger = logger;
_queue = queue;
_debugMode = config.GetValue<bool>("DEBUG", false);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
_collector = new LinuxMetricCollector();
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
_collector = new WindowsMetricCollector();
else
_collector = new MacMetricCollector();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Agent started on {platform}", RuntimeInformation.OSDescription);
if (_debugMode)
{
_logger.LogInformation("DEBUG MODE ENABLED - Metrics will be printed, not sent");
await RunDebugMode(stoppingToken);
}
else
{
await RunNormalMode(stoppingToken);
}
}
private async Task RunDebugMode(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Skip if previous execution is still running (non-blocking lock)
if (!_executionLock.Wait(0))
{
_logger.LogDebug("Skipping tick: previous execution still running");
continue;
}
try
{
var metrics = _collector.Collect();
var delta = CalculateDelta(metrics);
Console.WriteLine("\n=== METRICS DEBUG OUTPUT ===");
Console.WriteLine($"Machine: {delta.MachineName}");
if (delta.OSDescription != null)
Console.WriteLine($"OS: {delta.OSDescription}");
if (delta.IpAddress != null)
Console.WriteLine($"IP: {delta.IpAddress}");
if (delta.HwId != null)
Console.WriteLine($"HWID: {delta.HwId}");
if (delta.CpuUsagePercentage.HasValue)
Console.WriteLine($"CPU: {delta.CpuUsagePercentage:F2}%");
if (delta.MemoryUsedBytes.HasValue)
Console.WriteLine($"Memory: {delta.MemoryUsedBytes.Value / (1024 * 1024)} MB / {delta.MemoryTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB");
if (delta.SwapUsedBytes.HasValue)
Console.WriteLine($"Swap: {delta.SwapUsedBytes.Value / (1024 * 1024)} MB / {delta.SwapTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB");
if (delta.DiskUsedBytes.HasValue)
Console.WriteLine($"Disk: {delta.DiskUsedBytes.Value / (1024 * 1024)} MB / {delta.DiskTotalBytes.GetValueOrDefault() / (1024 * 1024)} MB ({delta.DiskMountPoint})");
Console.WriteLine($"Timestamp: {delta.Timestamp:O}");
Console.WriteLine("===========================\n");
_previousMetrics = metrics;
}
catch (Exception ex)
{
_logger.LogError("Error in debug mode: {msg}", ex.Message);
}
finally
{
_executionLock.Release();
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Debug mode cancelled");
}
}
private async Task RunNormalMode(CancellationToken stoppingToken)
{
// Create gRPC channel (ignore self-signed certs for development)
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;
_channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
HttpHandler = handler
});
_client = new MetricsService.MetricsServiceClient(_channel);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
// Skip if previous execution is still running (non-blocking lock)
if (!_executionLock.Wait(0))
{
_logger.LogWarning("Skipping tick: previous execution still running (>5s)");
continue;
}
try
{
// Try to flush queued metrics first
if (_queue.Count > 0)
{
var queuedMetrics = _queue.DequeueAll();
_logger.LogInformation("Flushing {count} queued metrics", queuedMetrics.Count);
foreach (var queued in queuedMetrics)
{
try
{
await _client.ReportMetricsAsync(queued, cancellationToken: stoppingToken);
}
catch (Exception ex)
{
_logger.LogError("Failed to flush queued metric: {msg}", ex.Message);
_queue.Enqueue(queued); // Re-queue on failure
// Continue trying other queued metrics instead of breaking
}
}
}
var metrics = _collector.Collect();
_logger.LogDebug("Collected metrics: CPU={cpu}%, Memory={mem}MB/{total}MB",
metrics.CpuUsagePercentage.ToString("F2"),
(metrics.MemoryUsedBytes / (1024 * 1024)),
(metrics.MemoryTotalBytes / (1024 * 1024)));
var delta = CalculateDelta(metrics);
// Convert to gRPC message using object initializer
var grpcDelta = new JMon.Protos.DeltaMetrics
{
MachineName = delta.MachineName,
Hwid = delta.HwId,
TimestampUnixMs = new DateTimeOffset(delta.Timestamp).ToUnixTimeMilliseconds(),
ProcessId = delta.ProcessId
};
if (!string.IsNullOrEmpty(delta.OSDescription))
grpcDelta.OsDescription = delta.OSDescription;
if (delta.CpuUsagePercentage.HasValue)
grpcDelta.CpuUsagePercentage = delta.CpuUsagePercentage.Value;
if (delta.MemoryUsedBytes.HasValue)
grpcDelta.MemoryUsedBytes = delta.MemoryUsedBytes.Value;
if (delta.MemoryTotalBytes.HasValue)
grpcDelta.MemoryTotalBytes = delta.MemoryTotalBytes.Value;
if (delta.SwapUsedBytes.HasValue)
grpcDelta.SwapUsedBytes = delta.SwapUsedBytes.Value;
if (delta.SwapTotalBytes.HasValue)
grpcDelta.SwapTotalBytes = delta.SwapTotalBytes.Value;
if (delta.DiskUsedBytes.HasValue)
grpcDelta.DiskUsedBytes = delta.DiskUsedBytes.Value;
if (delta.DiskTotalBytes.HasValue)
grpcDelta.DiskTotalBytes = delta.DiskTotalBytes.Value;
if (!string.IsNullOrEmpty(delta.DiskMountPoint))
grpcDelta.DiskMountPoint = delta.DiskMountPoint;
if (!string.IsNullOrEmpty(delta.IpAddress))
grpcDelta.IpAddress = delta.IpAddress;
var response = await _client.ReportMetricsAsync(grpcDelta, cancellationToken: stoppingToken);
_logger.LogInformation("Metrics reported: {response}", response.Command);
// Handle server commands
if (response.Command == MetricsResponse.Types.CommandType.Sync)
{
_logger.LogInformation("Server requested full resync: {msg}", response.Message);
_previousMetrics = null; // Reset to send full update next time
}
else if (response.Command == MetricsResponse.Types.CommandType.Sigterm)
{
_logger.LogInformation("Server terminating process: {msg}", response.Message);
Environment.Exit(0);
}
_previousMetrics = metrics;
}
catch (Exception ex)
{
_logger.LogError("Error reporting metrics: {msg}", ex.Message);
// On failure, queue the FULL metrics for retry (not delta)
// This ensures the server can reconstruct properly even if it was restarted
if (ex is not OperationCanceledException)
{
var metrics = _collector.Collect();
var grpcDelta = new JMon.Protos.DeltaMetrics
{
MachineName = metrics.MachineName,
Hwid = metrics.HwId,
TimestampUnixMs = new DateTimeOffset(metrics.Timestamp).ToUnixTimeMilliseconds(),
Retried = true,
ProcessId = metrics.ProcessId,
// Send FULL metrics (all fields) on retry
OsDescription = metrics.OSDescription,
CpuUsagePercentage = metrics.CpuUsagePercentage,
MemoryUsedBytes = metrics.MemoryUsedBytes,
MemoryTotalBytes = metrics.MemoryTotalBytes,
SwapUsedBytes = metrics.SwapUsedBytes,
SwapTotalBytes = metrics.SwapTotalBytes,
DiskUsedBytes = metrics.DiskUsedBytes,
DiskTotalBytes = metrics.DiskTotalBytes,
DiskMountPoint = metrics.DiskMountPoint,
IpAddress = metrics.IpAddress
};
_queue.Enqueue(grpcDelta);
}
}
finally
{
_executionLock.Release();
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Normal mode cancelled");
}
}
private DeltaMetrics CalculateDelta(SystemMetrics current)
{
// First time - send everything
if (_previousMetrics == null)
{
return new DeltaMetrics(
current.MachineName,
current.OSDescription,
current.CpuUsagePercentage,
current.MemoryUsedBytes,
current.MemoryTotalBytes,
current.SwapUsedBytes,
current.SwapTotalBytes,
current.DiskUsedBytes,
current.DiskTotalBytes,
current.DiskMountPoint,
current.Timestamp,
current.IpAddress,
current.HwId
);
}
// Calculate deltas and only include changed values
return new DeltaMetrics(
current.MachineName,
current.OSDescription != _previousMetrics.OSDescription ? current.OSDescription : null,
Math.Abs(current.CpuUsagePercentage - _previousMetrics.CpuUsagePercentage) > CpuThreshold ? current.CpuUsagePercentage : null,
Math.Abs(current.MemoryUsedBytes - _previousMetrics.MemoryUsedBytes) > MemoryThreshold ? current.MemoryUsedBytes : null,
current.MemoryTotalBytes != _previousMetrics.MemoryTotalBytes ? current.MemoryTotalBytes : null,
Math.Abs(current.SwapUsedBytes - _previousMetrics.SwapUsedBytes) > SwapThreshold ? current.SwapUsedBytes : null,
current.SwapTotalBytes != _previousMetrics.SwapTotalBytes ? current.SwapTotalBytes : null,
Math.Abs(current.DiskUsedBytes - _previousMetrics.DiskUsedBytes) > DiskThreshold ? current.DiskUsedBytes : null,
current.DiskTotalBytes != _previousMetrics.DiskTotalBytes ? current.DiskTotalBytes : null,
current.DiskMountPoint != _previousMetrics.DiskMountPoint ? current.DiskMountPoint : null,
current.Timestamp,
current.IpAddress != _previousMetrics.IpAddress ? current.IpAddress : null,
current.HwId // Always send HWID as it's the unique identifier
);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_channel != null)
{
await _channel.ShutdownAsync();
_channel.Dispose();
}
await base.StopAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
}
}