diff --git a/src/Vantage.Core/Alerts/AlertRule.cs b/src/Vantage.Core/Alerts/AlertRule.cs new file mode 100644 index 0000000..e03c799 --- /dev/null +++ b/src/Vantage.Core/Alerts/AlertRule.cs @@ -0,0 +1,49 @@ +namespace Vantage.Core.Alerts; + +public enum AlertComparison +{ + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual +} + +public enum AlertSeverity +{ + Info, + Warning, + Critical +} + +public sealed class AlertRule +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Name { get; set; } = ""; + public string? HostId { get; set; } + public string Metric { get; set; } = ""; + public AlertComparison Comparison { get; set; } = AlertComparison.GreaterThan; + public double Threshold { get; set; } + public TimeSpan For { get; set; } = TimeSpan.Zero; + public AlertSeverity Severity { get; set; } = AlertSeverity.Warning; + public bool Enabled { get; set; } = true; + + public bool AppliesTo(string hostId) => HostId is null || HostId == hostId; + + public bool IsBreaching(double value) => Comparison switch + { + AlertComparison.GreaterThan => value > Threshold, + AlertComparison.GreaterOrEqual => value >= Threshold, + AlertComparison.LessThan => value < Threshold, + AlertComparison.LessOrEqual => value <= Threshold, + _ => false + }; + + public string ComparisonSymbol => Comparison switch + { + AlertComparison.GreaterThan => ">", + AlertComparison.GreaterOrEqual => ">=", + AlertComparison.LessThan => "<", + AlertComparison.LessOrEqual => "<=", + _ => "?" + }; +} diff --git a/src/Vantage.Core/Alerts/AlertRuleEvaluator.cs b/src/Vantage.Core/Alerts/AlertRuleEvaluator.cs new file mode 100644 index 0000000..5205aa5 --- /dev/null +++ b/src/Vantage.Core/Alerts/AlertRuleEvaluator.cs @@ -0,0 +1,71 @@ +namespace Vantage.Core.Alerts; + +public sealed class AlertRuleEvaluator +{ + private readonly AlertRule rule; + private readonly string hostId; + private DateTimeOffset? breachingSince; + + public AlertRuleEvaluator(AlertRule rule, string hostId) + { + this.rule = rule; + this.hostId = hostId; + } + + public AlertState State { get; private set; } = AlertState.Ok; + public DateTimeOffset Since { get; private set; } + public double LastValue { get; private set; } + + public AlertNotification? Update(double value, DateTimeOffset now) + { + LastValue = value; + + if (rule.IsBreaching(value)) + return OnBreach(value, now); + + return OnClear(value, now); + } + + private AlertNotification? OnBreach(double value, DateTimeOffset now) + { + breachingSince ??= now; + + if (State == AlertState.Firing) + return null; + + var breachedFor = now - breachingSince.Value; + if (breachedFor >= rule.For) + { + State = AlertState.Firing; + Since = now; + return Notification(AlertTransitionKind.Fired, value, now); + } + + if (State != AlertState.Pending) + { + State = AlertState.Pending; + Since = breachingSince.Value; + } + + return null; + } + + private AlertNotification? OnClear(double value, DateTimeOffset now) + { + breachingSince = null; + var wasFiring = State == AlertState.Firing; + var changed = State != AlertState.Ok; + + State = AlertState.Ok; + if (changed) + Since = now; + + return wasFiring ? Notification(AlertTransitionKind.Resolved, value, now) : null; + } + + public AlertStatus Snapshot() => + new(rule.Id, rule.Name, hostId, rule.Metric, rule.Severity, State, LastValue, rule.Threshold, rule.ComparisonSymbol, Since); + + private AlertNotification Notification(AlertTransitionKind kind, double value, DateTimeOffset now) => + new(rule.Id, rule.Name, hostId, rule.Metric, rule.Severity, kind, value, rule.Threshold, rule.ComparisonSymbol, rule.For, now); +} diff --git a/src/Vantage.Core/Alerts/AlertState.cs b/src/Vantage.Core/Alerts/AlertState.cs new file mode 100644 index 0000000..6ebc497 --- /dev/null +++ b/src/Vantage.Core/Alerts/AlertState.cs @@ -0,0 +1,48 @@ +namespace Vantage.Core.Alerts; + +public enum AlertState +{ + Ok, + Pending, + Firing +} + +public enum AlertTransitionKind +{ + Fired, + Resolved +} + +public sealed record AlertNotification( + string RuleId, + string RuleName, + string HostId, + string Metric, + AlertSeverity Severity, + AlertTransitionKind Kind, + double Value, + double Threshold, + string ComparisonSymbol, + TimeSpan For, + DateTimeOffset Timestamp) +{ + public string Summary() + { + var scope = $"{Metric} {ComparisonSymbol} {Threshold:0.##}"; + return Kind == AlertTransitionKind.Fired + ? $"FIRING [{Severity}] {RuleName} on {HostId}: {scope} (is {Value:0.##})" + : $"RESOLVED [{Severity}] {RuleName} on {HostId}: {scope} (is {Value:0.##})"; + } +} + +public sealed record AlertStatus( + string RuleId, + string RuleName, + string HostId, + string Metric, + AlertSeverity Severity, + AlertState State, + double LastValue, + double Threshold, + string ComparisonSymbol, + DateTimeOffset Since); diff --git a/src/Vantage.Core/Alerts/IAlertLog.cs b/src/Vantage.Core/Alerts/IAlertLog.cs new file mode 100644 index 0000000..5267b1c --- /dev/null +++ b/src/Vantage.Core/Alerts/IAlertLog.cs @@ -0,0 +1,10 @@ +namespace Vantage.Core.Alerts; + +public sealed record AlertLogEntry(DateTimeOffset Timestamp, AlertSeverity Severity, string Line); + +public interface IAlertLog +{ + void Append(AlertLogEntry entry); + + IReadOnlyList Recent(int max); +} diff --git a/src/Vantage.Core/Alerts/InMemoryAlertLog.cs b/src/Vantage.Core/Alerts/InMemoryAlertLog.cs new file mode 100644 index 0000000..f42ee5b --- /dev/null +++ b/src/Vantage.Core/Alerts/InMemoryAlertLog.cs @@ -0,0 +1,33 @@ +namespace Vantage.Core.Alerts; + +public sealed class InMemoryAlertLog : IAlertLog +{ + private readonly object gate = new(); + private readonly LinkedList entries = new(); + private readonly int capacity; + + public InMemoryAlertLog(int capacity = 500) + { + if (capacity < 1) + throw new ArgumentOutOfRangeException(nameof(capacity)); + this.capacity = capacity; + } + + public void Append(AlertLogEntry entry) + { + lock (gate) + { + entries.AddLast(entry); + while (entries.Count > capacity) + entries.RemoveFirst(); + } + } + + public IReadOnlyList Recent(int max) + { + lock (gate) + { + return entries.Reverse().Take(max).ToArray(); + } + } +}