Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions argus-cli/src/main/java/io/argus/cli/ArgusCli.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.argus.cli;

import io.argus.cli.command.AlertCommand;
import io.argus.cli.command.Command;
import io.argus.cli.command.CommandExitException;
import io.argus.cli.command.ClusterCommand;
Expand Down Expand Up @@ -154,6 +155,7 @@ public static void main(String[] args) {

// Register all commands
Map<String, Command> commands = new LinkedHashMap<>();
register(commands, new AlertCommand());
register(commands, new ClusterCommand());
register(commands, new InitCommand());
register(commands, new PsCommand());
Expand Down
170 changes: 170 additions & 0 deletions argus-cli/src/main/java/io/argus/cli/alert/AlertEngine.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package io.argus.cli.alert;

import io.argus.cli.cluster.PrometheusTextParser;
import io.argus.cli.render.AnsiStyle;
import io.argus.cli.render.RichRenderer;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
* Polls a Prometheus endpoint at a configurable interval, evaluates alert rules
* against current metric values, and dispatches webhook notifications on breach.
*
* <p>Deduplication: an alert fires once when it first breaches its threshold, and
* is suppressed until the metric returns to a non-breaching state.
*/
public final class AlertEngine {

private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss");
private static final int WIDTH = RichRenderer.DEFAULT_WIDTH;

private final String target;
private final int intervalSeconds;
private final List<AlertRule> rules;
private final boolean useColor;
private final WebhookSender webhookSender;

/** Tracks which rule names are currently in a fired (breached) state. */
private final Set<String> firedAlerts = new HashSet<>();

public AlertEngine(String target, int intervalSeconds, List<AlertRule> rules, boolean useColor) {
this.target = target;
this.intervalSeconds = intervalSeconds;
this.rules = rules;
this.useColor = useColor;
this.webhookSender = new WebhookSender();
}

/**
* Starts the polling loop. Blocks until interrupted (e.g. Ctrl+C).
*/
public void run() {
String intervalLabel = intervalSeconds + "s";
System.out.println(RichRenderer.boxHeader(useColor, "Alert Monitor",
WIDTH, target, "checking every " + intervalLabel));
System.out.println(RichRenderer.emptyLine(WIDTH));

String rulesLine = " Rules: " + rules.size() + " active";
System.out.println(RichRenderer.boxLine(
AnsiStyle.style(useColor, AnsiStyle.BOLD) + rulesLine
+ AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH));
System.out.println(RichRenderer.emptyLine(WIDTH));

HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println(RichRenderer.emptyLine(WIDTH));
System.out.println(RichRenderer.boxLine(
AnsiStyle.style(useColor, AnsiStyle.DIM) + " Stopped."
+ AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH));
System.out.println(RichRenderer.boxFooter(useColor, null, WIDTH));
}, "argus-alert-cleanup"));

try {
while (!Thread.currentThread().isInterrupted()) {
poll(client);
Thread.sleep(intervalSeconds * 1000L);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

private void poll(HttpClient client) {
String now = LocalTime.now().format(TIME_FMT);
Map<String, Double> metrics = fetchMetrics(client);

for (AlertRule rule : rules) {
Double rawValue = metrics.get(rule.metric());
if (rawValue == null) {
// Metric not found — treat as zero for threshold evaluation
rawValue = 0.0;
}
double value = rawValue;
boolean breached = rule.isBreached(value);

String line;
if (breached) {
String displayValue = formatValue(rule, value);
String thresholdDisplay = formatThreshold(rule);
boolean alreadyFired = firedAlerts.contains(rule.name());
if (!alreadyFired) {
firedAlerts.add(rule.name());
webhookSender.send(rule, value, target);
line = " [" + now + "] "
+ AnsiStyle.style(useColor, AnsiStyle.YELLOW) + "\u26a0 "
+ rule.name() + ": " + displayValue + " EXCEEDED \u2192 webhook sent"
+ AnsiStyle.style(useColor, AnsiStyle.RESET);
} else {
line = " [" + now + "] "
+ AnsiStyle.style(useColor, AnsiStyle.YELLOW) + "\u26a0 "
+ rule.name() + ": " + displayValue + " EXCEEDED (ongoing)"
+ AnsiStyle.style(useColor, AnsiStyle.RESET);
}
} else {
// Resolved — clear dedup state
firedAlerts.remove(rule.name());
String displayValue = formatValue(rule, value);
line = " [" + now + "] "
+ AnsiStyle.style(useColor, AnsiStyle.GREEN) + "\u2713 "
+ rule.name() + ": " + displayValue
+ AnsiStyle.style(useColor, AnsiStyle.RESET);
}
System.out.println(RichRenderer.boxLine(line, WIDTH));
}
}

private Map<String, Double> fetchMetrics(HttpClient client) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://" + target + "/prometheus"))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return PrometheusTextParser.parse(response.body());
}
System.err.println("[alert] HTTP " + response.statusCode() + " from " + target);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
System.err.println("[alert] Failed to reach " + target + ": " + e.getMessage());
}
return Map.of();
}

/**
* Formats the metric value for display. Percentage metrics are shown as percentages,
* boolean-style metrics (threshold=1) are shown as detected/not detected.
*/
private static String formatValue(AlertRule rule, double value) {
if (rule.threshold() <= 1.0 && rule.metric().contains("suspected")) {
return value >= 1.0 ? "detected" : "not detected";
}
// Show as percentage if metric name contains "ratio" or "overhead"
if (rule.metric().contains("ratio") || rule.metric().contains("overhead")) {
return String.format("%.1f%%", value * 100);
}
return String.format("%.1f", value);
}

private static String formatThreshold(AlertRule rule) {
if (rule.metric().contains("ratio") || rule.metric().contains("overhead")) {
return String.format("%.0f%%", rule.threshold() * 100);
}
return String.format("%.1f", rule.threshold());
}
}
30 changes: 30 additions & 0 deletions argus-cli/src/main/java/io/argus/cli/alert/AlertRule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.argus.cli.alert;

/**
* A single alerting rule that compares a Prometheus metric value against a threshold.
*
* @param name human-readable rule name (e.g. "gc-overhead")
* @param metric Prometheus metric name (e.g. "argus_gc_overhead_ratio")
* @param threshold numeric threshold value
* @param comparator "&gt;" or "&lt;" — defaults to "&gt;" when unrecognised
* @param severity "warning" or "critical"
* @param webhookUrl destination webhook URL (may be null)
*/
public record AlertRule(
String name,
String metric,
double threshold,
String comparator,
String severity,
String webhookUrl) {

/** Returns true when the given metric value breaches this rule's threshold. */
public boolean isBreached(double value) {
return switch (comparator) {
case "<" -> value < threshold;
case "<=" -> value <= threshold;
case ">=" -> value >= threshold;
default -> value > threshold; // ">" and fallback
};
}
}
95 changes: 95 additions & 0 deletions argus-cli/src/main/java/io/argus/cli/alert/WebhookSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.argus.cli.alert;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;

/**
* Sends a JSON alert payload to a configured webhook URL via HTTP POST.
*
* <p>Payload format:
* <pre>
* {
* "alert": "ArgusGCOverhead",
* "severity": "warning",
* "value": 12.3,
* "threshold": 10.0,
* "instance": "localhost:9202",
* "timestamp": "2024-01-01T14:24:01Z"
* }
* </pre>
*/
public final class WebhookSender {

private final HttpClient client;

public WebhookSender() {
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}

/**
* Posts an alert notification to the webhook URL of the given rule.
* Failures are logged to stderr but do not throw.
*/
public void send(AlertRule rule, double value, String instance) {
if (rule.webhookUrl() == null || rule.webhookUrl().isBlank()) {
return;
}
String alertName = "Argus" + toTitleCase(rule.name());
String timestamp = Instant.now().toString();
String json = "{"
+ "\"alert\":\"" + escapeJson(alertName) + "\","
+ "\"severity\":\"" + escapeJson(rule.severity()) + "\","
+ "\"value\":" + value + ","
+ "\"threshold\":" + rule.threshold() + ","
+ "\"instance\":\"" + escapeJson(instance) + "\","
+ "\"timestamp\":\"" + timestamp + "\""
+ "}";

try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(rule.webhookUrl()))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
System.err.println("[alert] Webhook returned HTTP " + response.statusCode()
+ " for rule '" + rule.name() + "'");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
System.err.println("[alert] Webhook delivery failed for rule '" + rule.name()
+ "': " + e.getMessage());
}
}

/** Converts "gc-overhead" → "GcOverhead" for use in alert names. */
private static String toTitleCase(String s) {
StringBuilder sb = new StringBuilder();
boolean nextUpper = true;
for (char c : s.toCharArray()) {
if (c == '-' || c == '_') {
nextUpper = true;
} else if (nextUpper) {
sb.append(Character.toUpperCase(c));
nextUpper = false;
} else {
sb.append(c);
}
}
return sb.toString();
}

private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
Loading
Loading