- Shutdown-Event sendet stopped_graceful statt stopping: der Server kennt "stopping" im Router, aber event_log.kind ist ein ENUM ohne diesen Wert. Folge war HTTP 500 nach dem Zustandswechsel, das Event fehlte in der Historie. - Default-Source auf "Predictalytics" korrigiert (Schreibweise des bereits angelegten Monitors auf dem Server). Heartbeat, Metrics-Payload, Lizenzvalidierung und der Startpfad ueber den Cache sind end-to-end gegen die Produktivserver getestet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
4.7 KiB
C#
142 lines
4.7 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using Serilog;
|
|
|
|
namespace Predictalytics.WinFormsHost.Services;
|
|
|
|
/// <summary>
|
|
/// Sends periodic dead-man's-switch heartbeats to the external Watchdog server
|
|
/// (POST /api/heartbeat) so an outage of this app — or the whole machine — raises
|
|
/// an alarm. A Watchdog outage must never impact the app: every call is best effort.
|
|
/// </summary>
|
|
public sealed class WatchdogHeartbeatService : IDisposable
|
|
{
|
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
|
private readonly string _baseUrl;
|
|
private readonly string _apiKey;
|
|
private readonly string _source;
|
|
private readonly string _instance;
|
|
private readonly int _intervalSeconds;
|
|
private readonly Func<object?>? _metadataProvider;
|
|
private readonly DateTime _startedUtc = DateTime.UtcNow;
|
|
|
|
private System.Threading.Timer? _timer;
|
|
private bool _lastSendFailed;
|
|
|
|
public WatchdogHeartbeatService(
|
|
string baseUrl,
|
|
string apiKey,
|
|
string source,
|
|
string instance,
|
|
int intervalSeconds,
|
|
Func<object?>? metadataProvider = null)
|
|
{
|
|
_baseUrl = baseUrl.TrimEnd('/');
|
|
_apiKey = apiKey;
|
|
_source = source;
|
|
_instance = string.IsNullOrWhiteSpace(instance) ? "default" : instance;
|
|
_intervalSeconds = Math.Max(15, intervalSeconds);
|
|
_metadataProvider = metadataProvider;
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_timer?.Dispose();
|
|
_timer = new System.Threading.Timer(
|
|
async _ => await SendHeartbeatAsync("ok"),
|
|
null, TimeSpan.Zero, TimeSpan.FromSeconds(_intervalSeconds));
|
|
Log.Information("🐕 Watchdog heartbeat started → {Url} (source={Source}, every {Interval}s)",
|
|
_baseUrl, _source, _intervalSeconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reports a planned shutdown so it is not alarmed as a crash.
|
|
/// Must be "stopped_graceful": the server's event_log.kind ENUM has no "stopping"
|
|
/// value, and that variant fails the insert with HTTP 500 after the state update.
|
|
/// </summary>
|
|
public void NotifyStopping()
|
|
{
|
|
try
|
|
{
|
|
var payload = new
|
|
{
|
|
source = _source,
|
|
instance = _instance,
|
|
kind = "stopped_graceful",
|
|
severity = "info",
|
|
message = "Predictalytics wird planmäßig beendet."
|
|
};
|
|
// Synchronous with a short cap: the form is closing and must not hang.
|
|
PostAsync("/api/event", payload).Wait(TimeSpan.FromSeconds(4));
|
|
}
|
|
catch
|
|
{
|
|
// Best effort only.
|
|
}
|
|
}
|
|
|
|
private async Task SendHeartbeatAsync(string status)
|
|
{
|
|
try
|
|
{
|
|
var payload = new
|
|
{
|
|
source = _source,
|
|
instance = _instance,
|
|
type = "heartbeat",
|
|
status,
|
|
message = (string?)null,
|
|
metrics = new
|
|
{
|
|
uptimeSec = (long)(DateTime.UtcNow - _startedUtc).TotalSeconds,
|
|
app = _metadataProvider?.Invoke()
|
|
},
|
|
group = "C# Applications",
|
|
interval = _intervalSeconds
|
|
};
|
|
|
|
await PostAsync("/api/heartbeat", payload);
|
|
|
|
if (_lastSendFailed)
|
|
{
|
|
_lastSendFailed = false;
|
|
Log.Information("🐕 Watchdog heartbeat wieder erfolgreich zugestellt.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log the first failure as warning, subsequent ones quietly (no log flood).
|
|
if (!_lastSendFailed)
|
|
{
|
|
_lastSendFailed = true;
|
|
Log.Warning("🐕 Watchdog heartbeat fehlgeschlagen (weitere Fehler werden unterdrückt): {Error}", ex.Message);
|
|
}
|
|
else
|
|
{
|
|
Log.Debug(ex, "Watchdog heartbeat failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PostAsync(string path, object payload)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + path);
|
|
request.Headers.Add("X-Watchdog-Key", _apiKey);
|
|
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
|
|
|
using var response = await _http.SendAsync(request);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
throw new HttpRequestException($"Watchdog API HTTP {(int)response.StatusCode}: {body}");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_timer?.Dispose();
|
|
_timer = null;
|
|
_http.Dispose();
|
|
}
|
|
}
|