@steadwing/openalerts 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { ALL_RULES } from "./rules.js";
2
+ import { DEFAULTS, } from "./types.js";
3
+ /** Create a fresh evaluator state. */
4
+ export function createEvaluatorState() {
5
+ const now = Date.now();
6
+ return {
7
+ windows: new Map(),
8
+ cooldowns: new Map(),
9
+ consecutives: new Map(),
10
+ hourlyAlerts: { count: 0, resetAt: now + 60 * 60 * 1000 },
11
+ lastHeartbeatTs: 0,
12
+ startedAt: now,
13
+ stats: {
14
+ messagesProcessed: 0,
15
+ messageErrors: 0,
16
+ messagesReceived: 0,
17
+ webhookErrors: 0,
18
+ stuckSessions: 0,
19
+ toolCalls: 0,
20
+ toolErrors: 0,
21
+ agentStarts: 0,
22
+ agentErrors: 0,
23
+ sessionsStarted: 0,
24
+ compactions: 0,
25
+ totalTokens: 0,
26
+ totalCostUsd: 0,
27
+ lastResetTs: now,
28
+ },
29
+ };
30
+ }
31
+ /**
32
+ * Warm the evaluator state from persisted events.
33
+ * Replays recent events to rebuild windows/counters without re-firing alerts.
34
+ */
35
+ export function warmFromHistory(state, events) {
36
+ for (const event of events) {
37
+ if (event.type === "alert") {
38
+ // Restore cooldowns from recent alerts
39
+ state.cooldowns.set(event.fingerprint, event.ts);
40
+ }
41
+ // Don't replay diagnostic/heartbeat events through rules —
42
+ // that would re-fire alerts. Just restore cooldown state.
43
+ }
44
+ }
45
+ /**
46
+ * Process a single event through all rules.
47
+ * Returns alerts that should be fired (already filtered by cooldown + hourly cap).
48
+ */
49
+ export function processEvent(state, config, event) {
50
+ const now = Date.now();
51
+ // Reset 24h stats daily
52
+ if (now - state.stats.lastResetTs > 24 * 60 * 60 * 1000) {
53
+ state.stats.messagesProcessed = 0;
54
+ state.stats.messageErrors = 0;
55
+ state.stats.messagesReceived = 0;
56
+ state.stats.webhookErrors = 0;
57
+ state.stats.stuckSessions = 0;
58
+ state.stats.toolCalls = 0;
59
+ state.stats.toolErrors = 0;
60
+ state.stats.agentStarts = 0;
61
+ state.stats.agentErrors = 0;
62
+ state.stats.sessionsStarted = 0;
63
+ state.stats.compactions = 0;
64
+ state.stats.totalTokens = 0;
65
+ state.stats.totalCostUsd = 0;
66
+ state.stats.lastResetTs = now;
67
+ }
68
+ // Track event types in stats
69
+ if (event.type === "infra.error") {
70
+ state.stats.webhookErrors++;
71
+ }
72
+ if (event.type === "tool.call" || event.type === "tool.error") {
73
+ state.stats.toolCalls++;
74
+ if (event.type === "tool.error")
75
+ state.stats.toolErrors++;
76
+ }
77
+ if (event.type === "agent.start") {
78
+ state.stats.agentStarts++;
79
+ }
80
+ if (event.type === "agent.error") {
81
+ state.stats.agentErrors++;
82
+ }
83
+ if (event.type === "session.start") {
84
+ state.stats.sessionsStarted++;
85
+ }
86
+ if (event.type === "llm.token_usage") {
87
+ if (typeof event.tokenCount === "number")
88
+ state.stats.totalTokens += event.tokenCount;
89
+ if (typeof event.costUsd === "number")
90
+ state.stats.totalCostUsd += event.costUsd;
91
+ }
92
+ if (event.type === "custom" && event.meta?.openclawHook === "message_received") {
93
+ state.stats.messagesReceived++;
94
+ }
95
+ if (event.type === "custom" && event.meta?.compaction === true) {
96
+ state.stats.compactions++;
97
+ }
98
+ // Reset hourly cap if expired
99
+ if (now >= state.hourlyAlerts.resetAt) {
100
+ state.hourlyAlerts.count = 0;
101
+ state.hourlyAlerts.resetAt = now + 60 * 60 * 1000;
102
+ }
103
+ const ctx = { state, config, now };
104
+ const fired = [];
105
+ for (const rule of ALL_RULES) {
106
+ const alert = rule.evaluate(event, ctx);
107
+ if (!alert)
108
+ continue;
109
+ // Check cooldown
110
+ const cooldownMs = resolveCooldownMs(config, rule);
111
+ const lastFired = state.cooldowns.get(alert.fingerprint);
112
+ if (lastFired && now - lastFired < cooldownMs)
113
+ continue;
114
+ // Check hourly cap
115
+ if (state.hourlyAlerts.count >= DEFAULTS.maxAlertsPerHour)
116
+ continue;
117
+ // Fire the alert
118
+ state.cooldowns.set(alert.fingerprint, now);
119
+ state.hourlyAlerts.count++;
120
+ fired.push(alert);
121
+ }
122
+ // Prune cooldown map if too large
123
+ if (state.cooldowns.size > DEFAULTS.maxCooldownEntries) {
124
+ const entries = [...state.cooldowns.entries()].sort((a, b) => a[1] - b[1]);
125
+ const toRemove = entries.slice(0, entries.length - DEFAULTS.maxCooldownEntries);
126
+ for (const [key] of toRemove) {
127
+ state.cooldowns.delete(key);
128
+ }
129
+ }
130
+ return fired;
131
+ }
132
+ /**
133
+ * Run the watchdog tick — checks for gateway-down condition.
134
+ * Called every 30 seconds by the engine timer.
135
+ */
136
+ export function processWatchdogTick(state, config) {
137
+ return processEvent(state, config, { type: "watchdog.tick", ts: Date.now() });
138
+ }
139
+ /** Resolve the effective cooldown for a rule. */
140
+ function resolveCooldownMs(config, rule) {
141
+ const override = config.rules?.[rule.id]?.cooldownMinutes;
142
+ if (typeof override === "number" && override > 0) {
143
+ return override * 60 * 1000;
144
+ }
145
+ const globalOverride = config.cooldownMinutes;
146
+ if (typeof globalOverride === "number" && globalOverride > 0) {
147
+ return globalOverride * 60 * 1000;
148
+ }
149
+ return rule.defaultCooldownMs;
150
+ }
@@ -0,0 +1,17 @@
1
+ import type { OpenAlertsEvent } from "./types.js";
2
+ export type EventListener = (event: OpenAlertsEvent) => void;
3
+ /**
4
+ * Simple pub/sub event bus for OpenAlertsEvents.
5
+ * Replaces framework-specific event subscriptions (e.g., OpenClaw's onDiagnosticEvent).
6
+ */
7
+ export declare class OpenAlertsEventBus {
8
+ private listeners;
9
+ /** Subscribe to all events. Returns an unsubscribe function. */
10
+ on(listener: EventListener): () => void;
11
+ /** Emit an event to all listeners. Errors in listeners are caught and logged. */
12
+ emit(event: OpenAlertsEvent): void;
13
+ /** Remove all listeners. */
14
+ clear(): void;
15
+ /** Current listener count. */
16
+ get size(): number;
17
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Simple pub/sub event bus for OpenAlertsEvents.
3
+ * Replaces framework-specific event subscriptions (e.g., OpenClaw's onDiagnosticEvent).
4
+ */
5
+ export class OpenAlertsEventBus {
6
+ listeners = new Set();
7
+ /** Subscribe to all events. Returns an unsubscribe function. */
8
+ on(listener) {
9
+ this.listeners.add(listener);
10
+ return () => { this.listeners.delete(listener); };
11
+ }
12
+ /** Emit an event to all listeners. Errors in listeners are caught and logged. */
13
+ emit(event) {
14
+ for (const listener of this.listeners) {
15
+ try {
16
+ listener(event);
17
+ }
18
+ catch (err) {
19
+ console.error("[openalerts] event listener error:", err);
20
+ }
21
+ }
22
+ }
23
+ /** Remove all listeners. */
24
+ clear() {
25
+ this.listeners.clear();
26
+ }
27
+ /** Current listener count. */
28
+ get size() {
29
+ return this.listeners.size;
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ import type { AlertEvent, EvaluatorState, StoredEvent } from "./types.js";
2
+ export declare function formatAlertMessage(alert: AlertEvent, opts?: {
3
+ diagnosisHint?: string;
4
+ }): string;
5
+ export declare function formatHealthOutput(opts: {
6
+ state: EvaluatorState;
7
+ channelActivity: Array<{
8
+ channel: string;
9
+ lastInbound: number | null;
10
+ }>;
11
+ activeAlerts: AlertEvent[];
12
+ platformConnected: boolean;
13
+ }): string;
14
+ export declare function formatAlertsOutput(events: StoredEvent[]): string;
@@ -0,0 +1,124 @@
1
+ // ─── Alert Messages (sent to user's channel) ────────────────────────────────
2
+ export function formatAlertMessage(alert, opts) {
3
+ const prefix = alert.severity === "critical"
4
+ ? "[OpenAlerts] CRITICAL: "
5
+ : "[OpenAlerts] ";
6
+ const lines = [prefix + alert.title, "", alert.detail, "", "/health for full status."];
7
+ if (alert.severity === "critical" && opts?.diagnosisHint) {
8
+ lines[lines.length - 1] = opts.diagnosisHint;
9
+ }
10
+ return lines.join("\n");
11
+ }
12
+ // ─── /health Command Output ──────────────────────────────────────────────────
13
+ export function formatHealthOutput(opts) {
14
+ const { state, channelActivity, activeAlerts, platformConnected } = opts;
15
+ const uptime = formatDuration(Date.now() - state.startedAt);
16
+ const heartbeatAgo = state.lastHeartbeatTs > 0
17
+ ? `${formatDuration(Date.now() - state.lastHeartbeatTs)} ago`
18
+ : "none yet";
19
+ const status = activeAlerts.length > 0 ? "DEGRADED" : "OK";
20
+ const lines = [
21
+ "System Health -- OpenAlerts",
22
+ "",
23
+ `Status: ${status}`,
24
+ `Uptime: ${uptime} | Last heartbeat: ${heartbeatAgo}`,
25
+ ];
26
+ // Active alerts
27
+ if (activeAlerts.length > 0) {
28
+ lines.push("");
29
+ lines.push(`!! ${activeAlerts.length} active alert${activeAlerts.length > 1 ? "s" : ""}:`);
30
+ for (const a of activeAlerts.slice(0, 5)) {
31
+ const ago = formatDuration(Date.now() - a.ts);
32
+ lines.push(` [${a.severity.toUpperCase()}] ${a.title} (${ago} ago)`);
33
+ }
34
+ }
35
+ // Channel activity
36
+ if (channelActivity.length > 0) {
37
+ lines.push("");
38
+ lines.push("Channels:");
39
+ for (const ch of channelActivity) {
40
+ const ago = ch.lastInbound
41
+ ? `active (${formatDuration(Date.now() - ch.lastInbound)} ago)`
42
+ : "no activity";
43
+ lines.push(` ${ch.channel.padEnd(10)}: ${ago}`);
44
+ }
45
+ }
46
+ // 24h stats
47
+ lines.push("");
48
+ const s = state.stats;
49
+ const rcvd = s.messagesReceived > 0 ? `, ${s.messagesReceived} received` : "";
50
+ lines.push(`24h: ${s.messagesProcessed} msgs processed${rcvd}, ${s.messageErrors} errors, ${s.stuckSessions} stuck`);
51
+ if (s.toolCalls > 0 || s.agentStarts > 0 || s.sessionsStarted > 0) {
52
+ const parts = [];
53
+ if (s.toolCalls > 0)
54
+ parts.push(`${s.toolCalls} tools${s.toolErrors > 0 ? ` (${s.toolErrors} err)` : ""}`);
55
+ if (s.agentStarts > 0)
56
+ parts.push(`${s.agentStarts} agents${s.agentErrors > 0 ? ` (${s.agentErrors} err)` : ""}`);
57
+ if (s.sessionsStarted > 0)
58
+ parts.push(`${s.sessionsStarted} sessions`);
59
+ if (s.compactions > 0)
60
+ parts.push(`${s.compactions} compactions`);
61
+ lines.push(` ${parts.join(", ")}`);
62
+ }
63
+ if (s.totalTokens > 0) {
64
+ const tokenStr = s.totalTokens >= 1000 ? `${(s.totalTokens / 1000).toFixed(1)}k` : `${s.totalTokens}`;
65
+ const costStr = s.totalCostUsd > 0 ? ` ($${s.totalCostUsd.toFixed(4)})` : "";
66
+ lines.push(` ${tokenStr} tokens${costStr}`);
67
+ }
68
+ // Platform
69
+ lines.push("");
70
+ lines.push(platformConnected
71
+ ? "Platform: connected (openalerts.dev)"
72
+ : "Platform: not connected (add apiKey for diagnosis)");
73
+ return lines.join("\n");
74
+ }
75
+ // ─── /alerts Command Output ──────────────────────────────────────────────────
76
+ export function formatAlertsOutput(events) {
77
+ const alerts = events.filter((e) => e.type === "alert");
78
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
79
+ const recent = alerts.filter((a) => a.ts >= cutoff);
80
+ if (recent.length === 0) {
81
+ return [
82
+ "Recent Alerts",
83
+ "",
84
+ "No alerts in the last 24h. All systems normal.",
85
+ ].join("\n");
86
+ }
87
+ const lines = [
88
+ `Recent Alerts (${recent.length} in 24h)`,
89
+ "",
90
+ ];
91
+ // Show most recent first, cap at 10
92
+ const shown = recent.slice(-10).reverse();
93
+ for (const alert of shown) {
94
+ const time = new Date(alert.ts).toLocaleTimeString([], {
95
+ hour: "2-digit",
96
+ minute: "2-digit",
97
+ });
98
+ lines.push(`[${alert.severity.toUpperCase()}] ${time} -- ${alert.title}`);
99
+ lines.push(` ${alert.detail}`);
100
+ lines.push("");
101
+ }
102
+ if (recent.length > 10) {
103
+ lines.push(`Showing 10 of ${recent.length} alerts.`);
104
+ }
105
+ return lines.join("\n").trimEnd();
106
+ }
107
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
108
+ function formatDuration(ms) {
109
+ if (ms < 0)
110
+ ms = 0;
111
+ const sec = Math.floor(ms / 1000);
112
+ if (sec < 60)
113
+ return `${sec}s`;
114
+ const min = Math.floor(sec / 60);
115
+ if (min < 60)
116
+ return `${min}m`;
117
+ const hr = Math.floor(min / 60);
118
+ const remainMin = min % 60;
119
+ if (hr < 24)
120
+ return remainMin > 0 ? `${hr}h ${remainMin}m` : `${hr}h`;
121
+ const days = Math.floor(hr / 24);
122
+ const remainHr = hr % 24;
123
+ return remainHr > 0 ? `${days}d ${remainHr}h` : `${days}d`;
124
+ }
@@ -0,0 +1,11 @@
1
+ export type { AlertChannel, AlertEvent, AlertRuleDefinition, AlertSeverity, AlertTarget, DiagnosticSnapshot, EvaluatorState, HeartbeatSnapshot, MonitorConfig, RuleContext, RuleOverride, OpenAlertsEvent, OpenAlertsEventType, OpenAlertsInitOptions, OpenAlertsLogger, StoredEvent, WindowEntry, } from "./types.js";
2
+ export { DEFAULTS, LOG_FILENAME, STORE_DIR_NAME } from "./types.js";
3
+ export { OpenAlertsEngine } from "./engine.js";
4
+ export { OpenAlertsEventBus } from "./event-bus.js";
5
+ export { AlertDispatcher } from "./alert-channel.js";
6
+ export { createEvaluatorState, processEvent, processWatchdogTick, warmFromHistory, } from "./evaluator.js";
7
+ export { ALL_RULES } from "./rules.js";
8
+ export { appendEvent, pruneLog, readAllEvents, readRecentEvents, } from "./store.js";
9
+ export { formatAlertMessage, formatAlertsOutput, formatHealthOutput, } from "./formatter.js";
10
+ export { createPlatformSync, type PlatformSync } from "./platform.js";
11
+ export { BoundedMap, type BoundedMapOptions, type BoundedMapStats, } from "./bounded-map.js";
@@ -0,0 +1,21 @@
1
+ // OpenAlerts core — engine, rules, evaluator, store
2
+ // Constants
3
+ export { DEFAULTS, LOG_FILENAME, STORE_DIR_NAME } from "./types.js";
4
+ // Engine
5
+ export { OpenAlertsEngine } from "./engine.js";
6
+ // Event Bus
7
+ export { OpenAlertsEventBus } from "./event-bus.js";
8
+ // Alert Dispatcher
9
+ export { AlertDispatcher } from "./alert-channel.js";
10
+ // Evaluator
11
+ export { createEvaluatorState, processEvent, processWatchdogTick, warmFromHistory, } from "./evaluator.js";
12
+ // Rules
13
+ export { ALL_RULES } from "./rules.js";
14
+ // Store
15
+ export { appendEvent, pruneLog, readAllEvents, readRecentEvents, } from "./store.js";
16
+ // Formatter
17
+ export { formatAlertMessage, formatAlertsOutput, formatHealthOutput, } from "./formatter.js";
18
+ // Platform
19
+ export { createPlatformSync } from "./platform.js";
20
+ // Bounded Map
21
+ export { BoundedMap, } from "./bounded-map.js";
@@ -0,0 +1,17 @@
1
+ import { type OpenAlertsLogger, type StoredEvent } from "./types.js";
2
+ export type PlatformSync = {
3
+ enqueue: (event: StoredEvent) => void;
4
+ flush: () => Promise<void>;
5
+ stop: () => void;
6
+ isConnected: () => boolean;
7
+ };
8
+ /**
9
+ * Create a platform sync instance that batches events and pushes them
10
+ * to the OpenAlerts backend API. Only active when apiKey is provided.
11
+ */
12
+ export declare function createPlatformSync(opts: {
13
+ apiKey: string;
14
+ baseUrl?: string;
15
+ logger: OpenAlertsLogger;
16
+ logPrefix?: string;
17
+ }): PlatformSync;
@@ -0,0 +1,93 @@
1
+ import { DEFAULTS } from "./types.js";
2
+ /**
3
+ * Create a platform sync instance that batches events and pushes them
4
+ * to the OpenAlerts backend API. Only active when apiKey is provided.
5
+ */
6
+ export function createPlatformSync(opts) {
7
+ const { apiKey, logger } = opts;
8
+ const baseUrl = opts.baseUrl?.replace(/\/+$/, "") ?? "https://api.openalerts.dev";
9
+ const prefix = opts.logPrefix ?? "openalerts";
10
+ let batch = [];
11
+ let flushTimer = null;
12
+ let disabled = false;
13
+ let connected = true;
14
+ // Start periodic flush
15
+ flushTimer = setInterval(() => {
16
+ void doFlush().catch(() => { });
17
+ }, DEFAULTS.platformFlushIntervalMs);
18
+ async function doFlush() {
19
+ if (disabled || batch.length === 0)
20
+ return;
21
+ const events = batch.splice(0, DEFAULTS.platformBatchSize);
22
+ const body = JSON.stringify({
23
+ events,
24
+ plugin_version: "0.1.0",
25
+ ts: Date.now(),
26
+ });
27
+ let lastErr;
28
+ for (let attempt = 0; attempt < 2; attempt++) {
29
+ try {
30
+ const res = await fetch(`${baseUrl}/api/monitor/ingest`, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ Authorization: `Bearer ${apiKey}`,
35
+ },
36
+ body,
37
+ signal: AbortSignal.timeout(15_000),
38
+ });
39
+ if (res.ok) {
40
+ connected = true;
41
+ return; // Success
42
+ }
43
+ if (res.status === 401 || res.status === 403) {
44
+ logger.warn(`${prefix}: invalid API key (${res.status}). Platform sync disabled. Check your key at app.openalerts.dev.`);
45
+ disabled = true;
46
+ connected = false;
47
+ return;
48
+ }
49
+ lastErr = `HTTP ${res.status}`;
50
+ }
51
+ catch (err) {
52
+ lastErr = err;
53
+ }
54
+ // Wait before retry
55
+ if (attempt < 1) {
56
+ await new Promise((r) => setTimeout(r, 5000));
57
+ }
58
+ }
59
+ // Failed after retries — put events back and log
60
+ batch.unshift(...events);
61
+ // Cap batch to prevent unbounded growth
62
+ if (batch.length > DEFAULTS.platformBatchSize * 2) {
63
+ batch = batch.slice(-DEFAULTS.platformBatchSize);
64
+ }
65
+ connected = false;
66
+ logger.warn(`${prefix}: platform sync failed: ${String(lastErr)}`);
67
+ }
68
+ return {
69
+ enqueue(event) {
70
+ if (disabled)
71
+ return;
72
+ batch.push(event);
73
+ // Auto-flush if batch full
74
+ if (batch.length >= DEFAULTS.platformBatchSize) {
75
+ void doFlush().catch(() => { });
76
+ }
77
+ },
78
+ async flush() {
79
+ await doFlush();
80
+ },
81
+ stop() {
82
+ if (flushTimer) {
83
+ clearInterval(flushTimer);
84
+ flushTimer = null;
85
+ }
86
+ // Final flush attempt (best-effort, don't await in stop)
87
+ void doFlush().catch(() => { });
88
+ },
89
+ isConnected() {
90
+ return connected && !disabled;
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,2 @@
1
+ import { type AlertRuleDefinition } from "./types.js";
2
+ export declare const ALL_RULES: AlertRuleDefinition[];