@hasna/logs 0.0.1 → 0.1.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.
@@ -1,31 +1,50 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
  import {
4
+ deletePageAuth,
5
+ runRetentionForProject,
6
+ setPageAuth,
7
+ setRetentionPolicy,
4
8
  startScheduler
5
- } from "../index-dh02dp7n.js";
9
+ } from "../index-4x090f69.js";
6
10
  import {
11
+ createAlertRule,
7
12
  createPage,
8
13
  createProject,
14
+ deleteAlertRule,
9
15
  getDb,
16
+ getIssue,
10
17
  getLatestSnapshot,
11
18
  getLogContext,
12
19
  getPerfTrend,
13
20
  getProject,
14
21
  ingestBatch,
15
22
  ingestLog,
23
+ listAlertRules,
24
+ listIssues,
16
25
  listPages,
17
26
  listProjects,
18
27
  searchLogs,
19
28
  summarizeLogs,
20
29
  tailLogs,
30
+ updateAlertRule,
31
+ updateIssueStatus,
21
32
  updateProject
22
- } from "../index-zj6ymcv7.js";
33
+ } from "../index-qmsvtxax.js";
23
34
  import {
24
35
  createJob,
25
36
  deleteJob,
26
37
  listJobs,
27
38
  updateJob
28
- } from "../index-4mnved04.js";
39
+ } from "../jobs-124e878j.js";
40
+ import {
41
+ exportToCsv,
42
+ exportToJson
43
+ } from "../export-yjaw2sr3.js";
44
+ import {
45
+ getHealth
46
+ } from "../health-f2qrebqc.js";
47
+ import"../index-g8dczzvv.js";
29
48
 
30
49
  // node_modules/hono/dist/compose.js
31
50
  var compose = (middleware, onError, onNotFound) => {
@@ -1674,6 +1693,71 @@ window.__logs={push:push,flush:flush,config:cfg};
1674
1693
  })();`;
1675
1694
  }
1676
1695
 
1696
+ // src/server/routes/alerts.ts
1697
+ function alertsRoutes(db) {
1698
+ const app = new Hono2;
1699
+ app.post("/", async (c) => {
1700
+ const body = await c.req.json();
1701
+ if (!body.project_id || !body.name)
1702
+ return c.json({ error: "project_id and name required" }, 422);
1703
+ return c.json(createAlertRule(db, body), 201);
1704
+ });
1705
+ app.get("/", (c) => {
1706
+ const { project_id } = c.req.query();
1707
+ return c.json(listAlertRules(db, project_id || undefined));
1708
+ });
1709
+ app.put("/:id", async (c) => {
1710
+ const body = await c.req.json();
1711
+ const updated = updateAlertRule(db, c.req.param("id"), body);
1712
+ if (!updated)
1713
+ return c.json({ error: "not found" }, 404);
1714
+ return c.json(updated);
1715
+ });
1716
+ app.delete("/:id", (c) => {
1717
+ deleteAlertRule(db, c.req.param("id"));
1718
+ return c.json({ deleted: true });
1719
+ });
1720
+ return app;
1721
+ }
1722
+
1723
+ // src/server/routes/issues.ts
1724
+ function issuesRoutes(db) {
1725
+ const app = new Hono2;
1726
+ app.get("/", (c) => {
1727
+ const { project_id, status, limit } = c.req.query();
1728
+ return c.json(listIssues(db, project_id || undefined, status || undefined, limit ? Number(limit) : 50));
1729
+ });
1730
+ app.get("/:id", (c) => {
1731
+ const issue = getIssue(db, c.req.param("id"));
1732
+ if (!issue)
1733
+ return c.json({ error: "not found" }, 404);
1734
+ return c.json(issue);
1735
+ });
1736
+ app.get("/:id/logs", (c) => {
1737
+ const issue = getIssue(db, c.req.param("id"));
1738
+ if (!issue)
1739
+ return c.json({ error: "not found" }, 404);
1740
+ const rows = searchLogs(db, {
1741
+ project_id: issue.project_id ?? undefined,
1742
+ level: issue.level,
1743
+ service: issue.service ?? undefined,
1744
+ text: issue.message_template.slice(0, 50),
1745
+ limit: 50
1746
+ });
1747
+ return c.json(rows);
1748
+ });
1749
+ app.put("/:id", async (c) => {
1750
+ const { status } = await c.req.json();
1751
+ if (!["open", "resolved", "ignored"].includes(status))
1752
+ return c.json({ error: "invalid status" }, 422);
1753
+ const updated = updateIssueStatus(db, c.req.param("id"), status);
1754
+ if (!updated)
1755
+ return c.json({ error: "not found" }, 404);
1756
+ return c.json(updated);
1757
+ });
1758
+ return app;
1759
+ }
1760
+
1677
1761
  // src/server/routes/jobs.ts
1678
1762
  function jobsRoutes(db) {
1679
1763
  const app = new Hono2;
@@ -1747,6 +1831,23 @@ function logsRoutes(db) {
1747
1831
  const rows = getLogContext(db, c.req.param("trace_id"));
1748
1832
  return c.json(rows);
1749
1833
  });
1834
+ app.get("/export", (c) => {
1835
+ const { project_id, since, until, level, service, format, limit } = c.req.query();
1836
+ const opts = { project_id: project_id || undefined, since: since || undefined, until: until || undefined, level: level || undefined, service: service || undefined, limit: limit ? Number(limit) : undefined };
1837
+ if (format === "csv") {
1838
+ c.header("Content-Type", "text/csv");
1839
+ c.header("Content-Disposition", "attachment; filename=logs.csv");
1840
+ const chunks2 = [];
1841
+ exportToCsv(db, opts, (s) => chunks2.push(s));
1842
+ return c.text(chunks2.join(""));
1843
+ }
1844
+ c.header("Content-Type", "application/json");
1845
+ c.header("Content-Disposition", "attachment; filename=logs.json");
1846
+ const chunks = [];
1847
+ exportToJson(db, opts, (s) => chunks.push(s));
1848
+ return c.text(chunks.join(`
1849
+ `));
1850
+ });
1750
1851
  return app;
1751
1852
  }
1752
1853
 
@@ -1823,6 +1924,26 @@ function projectsRoutes(db) {
1823
1924
  return c.json(page, 201);
1824
1925
  });
1825
1926
  app.get("/:id/pages", (c) => c.json(listPages(db, c.req.param("id"))));
1927
+ app.put("/:id/retention", async (c) => {
1928
+ const body = await c.req.json();
1929
+ setRetentionPolicy(db, c.req.param("id"), body);
1930
+ return c.json({ updated: true });
1931
+ });
1932
+ app.post("/:id/retention/run", (c) => {
1933
+ const result = runRetentionForProject(db, c.req.param("id"));
1934
+ return c.json(result);
1935
+ });
1936
+ app.post("/:id/pages/:page_id/auth", async (c) => {
1937
+ const { type, credentials } = await c.req.json();
1938
+ if (!type || !credentials)
1939
+ return c.json({ error: "type and credentials required" }, 422);
1940
+ const result = setPageAuth(db, c.req.param("page_id"), type, credentials);
1941
+ return c.json({ id: result.id, type: result.type, created_at: result.created_at }, 201);
1942
+ });
1943
+ app.delete("/:id/pages/:page_id/auth", (c) => {
1944
+ deletePageAuth(db, c.req.param("page_id"));
1945
+ return c.json({ deleted: true });
1946
+ });
1826
1947
  app.post("/:id/sync-repo", async (c) => {
1827
1948
  const project = getProject(db, c.req.param("id"));
1828
1949
  if (!project)
@@ -1835,6 +1956,191 @@ function projectsRoutes(db) {
1835
1956
  return app;
1836
1957
  }
1837
1958
 
1959
+ // node_modules/hono/dist/utils/stream.js
1960
+ var StreamingApi = class {
1961
+ writer;
1962
+ encoder;
1963
+ writable;
1964
+ abortSubscribers = [];
1965
+ responseReadable;
1966
+ aborted = false;
1967
+ closed = false;
1968
+ constructor(writable, _readable) {
1969
+ this.writable = writable;
1970
+ this.writer = writable.getWriter();
1971
+ this.encoder = new TextEncoder;
1972
+ const reader = _readable.getReader();
1973
+ this.abortSubscribers.push(async () => {
1974
+ await reader.cancel();
1975
+ });
1976
+ this.responseReadable = new ReadableStream({
1977
+ async pull(controller) {
1978
+ const { done, value } = await reader.read();
1979
+ done ? controller.close() : controller.enqueue(value);
1980
+ },
1981
+ cancel: () => {
1982
+ this.abort();
1983
+ }
1984
+ });
1985
+ }
1986
+ async write(input) {
1987
+ try {
1988
+ if (typeof input === "string") {
1989
+ input = this.encoder.encode(input);
1990
+ }
1991
+ await this.writer.write(input);
1992
+ } catch {}
1993
+ return this;
1994
+ }
1995
+ async writeln(input) {
1996
+ await this.write(input + `
1997
+ `);
1998
+ return this;
1999
+ }
2000
+ sleep(ms) {
2001
+ return new Promise((res) => setTimeout(res, ms));
2002
+ }
2003
+ async close() {
2004
+ try {
2005
+ await this.writer.close();
2006
+ } catch {}
2007
+ this.closed = true;
2008
+ }
2009
+ async pipe(body) {
2010
+ this.writer.releaseLock();
2011
+ await body.pipeTo(this.writable, { preventClose: true });
2012
+ this.writer = this.writable.getWriter();
2013
+ }
2014
+ onAbort(listener) {
2015
+ this.abortSubscribers.push(listener);
2016
+ }
2017
+ abort() {
2018
+ if (!this.aborted) {
2019
+ this.aborted = true;
2020
+ this.abortSubscribers.forEach((subscriber) => subscriber());
2021
+ }
2022
+ }
2023
+ };
2024
+
2025
+ // node_modules/hono/dist/helper/streaming/utils.js
2026
+ var isOldBunVersion = () => {
2027
+ const version = typeof Bun !== "undefined" ? Bun.version : undefined;
2028
+ if (version === undefined) {
2029
+ return false;
2030
+ }
2031
+ const result = version.startsWith("1.1") || version.startsWith("1.0") || version.startsWith("0.");
2032
+ isOldBunVersion = () => result;
2033
+ return result;
2034
+ };
2035
+
2036
+ // node_modules/hono/dist/helper/streaming/sse.js
2037
+ var SSEStreamingApi = class extends StreamingApi {
2038
+ constructor(writable, readable) {
2039
+ super(writable, readable);
2040
+ }
2041
+ async writeSSE(message) {
2042
+ const data = await resolveCallback(message.data, HtmlEscapedCallbackPhase.Stringify, false, {});
2043
+ const dataLines = data.split(/\r\n|\r|\n/).map((line) => {
2044
+ return `data: ${line}`;
2045
+ }).join(`
2046
+ `);
2047
+ for (const key of ["event", "id", "retry"]) {
2048
+ if (message[key] && /[\r\n]/.test(message[key])) {
2049
+ throw new Error(`${key} must not contain "\\r" or "\\n"`);
2050
+ }
2051
+ }
2052
+ const sseData = [
2053
+ message.event && `event: ${message.event}`,
2054
+ dataLines,
2055
+ message.id && `id: ${message.id}`,
2056
+ message.retry && `retry: ${message.retry}`
2057
+ ].filter(Boolean).join(`
2058
+ `) + `
2059
+
2060
+ `;
2061
+ await this.write(sseData);
2062
+ }
2063
+ };
2064
+ var run = async (stream, cb, onError) => {
2065
+ try {
2066
+ await cb(stream);
2067
+ } catch (e) {
2068
+ if (e instanceof Error && onError) {
2069
+ await onError(e, stream);
2070
+ await stream.writeSSE({
2071
+ event: "error",
2072
+ data: e.message
2073
+ });
2074
+ } else {
2075
+ console.error(e);
2076
+ }
2077
+ } finally {
2078
+ stream.close();
2079
+ }
2080
+ };
2081
+ var contextStash = /* @__PURE__ */ new WeakMap;
2082
+ var streamSSE = (c, cb, onError) => {
2083
+ const { readable, writable } = new TransformStream;
2084
+ const stream = new SSEStreamingApi(writable, readable);
2085
+ if (isOldBunVersion()) {
2086
+ c.req.raw.signal.addEventListener("abort", () => {
2087
+ if (!stream.closed) {
2088
+ stream.abort();
2089
+ }
2090
+ });
2091
+ }
2092
+ contextStash.set(stream.responseReadable, c);
2093
+ c.header("Transfer-Encoding", "chunked");
2094
+ c.header("Content-Type", "text/event-stream");
2095
+ c.header("Cache-Control", "no-cache");
2096
+ c.header("Connection", "keep-alive");
2097
+ run(stream, cb, onError);
2098
+ return c.newResponse(stream.responseReadable);
2099
+ };
2100
+
2101
+ // src/server/routes/stream.ts
2102
+ function streamRoutes(db) {
2103
+ const app = new Hono2;
2104
+ app.get("/", (c) => {
2105
+ const { project_id, level, service } = c.req.query();
2106
+ return streamSSE(c, async (stream2) => {
2107
+ let lastId = null;
2108
+ const latest = db.prepare("SELECT id FROM logs ORDER BY timestamp DESC LIMIT 1").get();
2109
+ lastId = latest?.id ?? null;
2110
+ while (true) {
2111
+ const conditions = [];
2112
+ const params = {};
2113
+ if (lastId) {
2114
+ conditions.push("rowid > (SELECT rowid FROM logs WHERE id = $lastId)");
2115
+ params.$lastId = lastId;
2116
+ }
2117
+ if (project_id) {
2118
+ conditions.push("project_id = $project_id");
2119
+ params.$project_id = project_id;
2120
+ }
2121
+ if (level) {
2122
+ conditions.push("level IN (" + level.split(",").map((l, i) => `$l${i}`).join(",") + ")");
2123
+ level.split(",").forEach((l, i) => {
2124
+ params[`$l${i}`] = l;
2125
+ });
2126
+ }
2127
+ if (service) {
2128
+ conditions.push("service = $service");
2129
+ params.$service = service;
2130
+ }
2131
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
2132
+ const rows = db.prepare(`SELECT * FROM logs ${where} ORDER BY timestamp ASC LIMIT 50`).all(params);
2133
+ for (const row of rows) {
2134
+ await stream2.writeSSE({ data: JSON.stringify(row), id: row.id, event: row.level });
2135
+ lastId = row.id;
2136
+ }
2137
+ await stream2.sleep(500);
2138
+ }
2139
+ });
2140
+ });
2141
+ return app;
2142
+ }
2143
+
1838
2144
  // src/server/index.ts
1839
2145
  var PORT = Number(process.env.LOGS_PORT ?? 3460);
1840
2146
  var db = getDb();
@@ -1847,9 +2153,13 @@ app.get("/script.js", (c) => {
1847
2153
  return c.text(getBrowserScript(host));
1848
2154
  });
1849
2155
  app.route("/api/logs", logsRoutes(db));
2156
+ app.route("/api/logs/stream", streamRoutes(db));
1850
2157
  app.route("/api/projects", projectsRoutes(db));
1851
2158
  app.route("/api/jobs", jobsRoutes(db));
2159
+ app.route("/api/alerts", alertsRoutes(db));
2160
+ app.route("/api/issues", issuesRoutes(db));
1852
2161
  app.route("/api/perf", perfRoutes(db));
2162
+ app.get("/health", (c) => c.json(getHealth(db)));
1853
2163
  app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok" }));
1854
2164
  startScheduler(db);
1855
2165
  console.log(`@hasna/logs server running on http://localhost:${PORT}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/logs",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Log aggregation + browser script + headless page scanner + performance monitoring for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,15 @@
21
21
  "access": "restricted",
22
22
  "registry": "https://registry.npmjs.org/"
23
23
  },
24
- "keywords": ["logs", "monitoring", "mcp", "ai-agents", "sentry", "performance", "lighthouse"],
24
+ "keywords": [
25
+ "logs",
26
+ "monitoring",
27
+ "mcp",
28
+ "ai-agents",
29
+ "sentry",
30
+ "performance",
31
+ "lighthouse"
32
+ ],
25
33
  "author": "Andrei Hasna <andrei@hasna.com>",
26
34
  "license": "MIT",
27
35
  "dependencies": {
package/src/cli/index.ts CHANGED
@@ -151,6 +151,50 @@ program.command("scan")
151
151
  console.log("Scan complete.")
152
152
  })
153
153
 
154
+ // ── logs export ───────────────────────────────────────────
155
+ program.command("export")
156
+ .description("Export logs to JSON or CSV")
157
+ .option("--project <id>")
158
+ .option("--since <time>", "Relative time or ISO")
159
+ .option("--level <level>")
160
+ .option("--service <name>")
161
+ .option("--format <fmt>", "json or csv", "json")
162
+ .option("--output <file>", "Output file (default: stdout)")
163
+ .option("--limit <n>", "Max rows", "100000")
164
+ .action(async (opts) => {
165
+ const { exportToCsv, exportToJson } = await import("../lib/export.ts")
166
+ const { createWriteStream } = await import("node:fs")
167
+ const db = getDb()
168
+ const options = {
169
+ project_id: opts.project,
170
+ since: parseRelativeTime(opts.since),
171
+ level: opts.level,
172
+ service: opts.service,
173
+ limit: Number(opts.limit),
174
+ }
175
+ let count = 0
176
+ if (opts.output) {
177
+ const stream = createWriteStream(opts.output)
178
+ const write = (s: string) => stream.write(s)
179
+ count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
180
+ stream.end()
181
+ console.error(`Exported ${count} log(s) to ${opts.output}`)
182
+ } else {
183
+ const write = (s: string) => process.stdout.write(s)
184
+ count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
185
+ process.stderr.write(`\nExported ${count} log(s)\n`)
186
+ }
187
+ })
188
+
189
+ // ── logs health ───────────────────────────────────────────
190
+ program.command("health")
191
+ .description("Show server health and DB stats")
192
+ .action(async () => {
193
+ const { getHealth } = await import("../lib/health.ts")
194
+ const h = getHealth(getDb())
195
+ console.log(JSON.stringify(h, null, 2))
196
+ })
197
+
154
198
  // ── logs mcp / logs serve ─────────────────────────────────
155
199
  program.command("mcp")
156
200
  .description("Start the MCP server")
package/src/db/index.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { Database } from "bun:sqlite"
2
2
  import { join } from "node:path"
3
3
  import { existsSync, mkdirSync } from "node:fs"
4
+ import { migrateAlertRules } from "./migrations/001_alert_rules.ts"
5
+ import { migrateIssues } from "./migrations/002_issues.ts"
6
+ import { migrateRetention } from "./migrations/003_retention.ts"
7
+ import { migratePageAuth } from "./migrations/004_page_auth.ts"
4
8
 
5
9
  const DATA_DIR = process.env.LOGS_DATA_DIR ?? join(process.env.HOME ?? "~", ".logs")
6
10
  const DB_PATH = process.env.LOGS_DB_PATH ?? join(DATA_DIR, "logs.db")
@@ -150,4 +154,10 @@ function migrate(db: Database): void {
150
154
 
151
155
  db.run(`CREATE INDEX IF NOT EXISTS idx_perf_project_ts ON performance_snapshots(project_id, timestamp DESC)`)
152
156
  db.run(`CREATE INDEX IF NOT EXISTS idx_perf_page ON performance_snapshots(page_id)`)
157
+
158
+ // QoL migrations
159
+ migrateAlertRules(db)
160
+ migrateIssues(db)
161
+ migrateRetention(db)
162
+ migratePageAuth(db)
153
163
  }
@@ -0,0 +1,21 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ export function migrateAlertRules(db: Database): void {
4
+ db.run(`
5
+ CREATE TABLE IF NOT EXISTS alert_rules (
6
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
7
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
8
+ name TEXT NOT NULL,
9
+ service TEXT,
10
+ level TEXT NOT NULL DEFAULT 'error' CHECK(level IN ('debug','info','warn','error','fatal')),
11
+ threshold_count INTEGER NOT NULL DEFAULT 10,
12
+ window_seconds INTEGER NOT NULL DEFAULT 60,
13
+ action TEXT NOT NULL DEFAULT 'webhook' CHECK(action IN ('webhook','log')),
14
+ webhook_url TEXT,
15
+ enabled INTEGER NOT NULL DEFAULT 1,
16
+ last_fired_at TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
18
+ )
19
+ `)
20
+ db.run(`CREATE INDEX IF NOT EXISTS idx_alert_rules_project ON alert_rules(project_id)`)
21
+ }
@@ -0,0 +1,21 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ export function migrateIssues(db: Database): void {
4
+ db.run(`
5
+ CREATE TABLE IF NOT EXISTS issues (
6
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
7
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
8
+ fingerprint TEXT NOT NULL,
9
+ level TEXT NOT NULL,
10
+ service TEXT,
11
+ message_template TEXT NOT NULL,
12
+ first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
13
+ last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
14
+ count INTEGER NOT NULL DEFAULT 1,
15
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','resolved','ignored')),
16
+ UNIQUE(project_id, fingerprint)
17
+ )
18
+ `)
19
+ db.run(`CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, status)`)
20
+ db.run(`CREATE INDEX IF NOT EXISTS idx_issues_fingerprint ON issues(fingerprint)`)
21
+ }
@@ -0,0 +1,15 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ const RETENTION_COLUMNS = [
4
+ "max_rows INTEGER NOT NULL DEFAULT 100000",
5
+ "debug_ttl_hours INTEGER NOT NULL DEFAULT 24",
6
+ "info_ttl_hours INTEGER NOT NULL DEFAULT 168",
7
+ "warn_ttl_hours INTEGER NOT NULL DEFAULT 720",
8
+ "error_ttl_hours INTEGER NOT NULL DEFAULT 2160",
9
+ ]
10
+
11
+ export function migrateRetention(db: Database): void {
12
+ for (const col of RETENTION_COLUMNS) {
13
+ try { db.run(`ALTER TABLE projects ADD COLUMN ${col}`) } catch { /* already exists */ }
14
+ }
15
+ }
@@ -0,0 +1,13 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ export function migratePageAuth(db: Database): void {
4
+ db.run(`
5
+ CREATE TABLE IF NOT EXISTS page_auth (
6
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
7
+ page_id TEXT NOT NULL UNIQUE REFERENCES pages(id) ON DELETE CASCADE,
8
+ type TEXT NOT NULL CHECK(type IN ('cookie','bearer','basic')),
9
+ credentials TEXT NOT NULL,
10
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
11
+ )
12
+ `)
13
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, mock } from "bun:test"
2
+ import { createTestDb } from "../db/index.ts"
3
+ import { createAlertRule, deleteAlertRule, evaluateAlerts, listAlertRules, updateAlertRule } from "./alerts.ts"
4
+ import { ingestBatch } from "./ingest.ts"
5
+
6
+ function seedProject(db: ReturnType<typeof createTestDb>) {
7
+ return db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
8
+ }
9
+
10
+ describe("alert rules CRUD", () => {
11
+ it("creates an alert rule", () => {
12
+ const db = createTestDb()
13
+ const p = seedProject(db)
14
+ const rule = createAlertRule(db, { project_id: p.id, name: "High errors", level: "error", threshold_count: 5, window_seconds: 60 })
15
+ expect(rule.id).toBeTruthy()
16
+ expect(rule.name).toBe("High errors")
17
+ expect(rule.threshold_count).toBe(5)
18
+ expect(rule.enabled).toBe(1)
19
+ })
20
+
21
+ it("lists rules for a project", () => {
22
+ const db = createTestDb()
23
+ const p = seedProject(db)
24
+ createAlertRule(db, { project_id: p.id, name: "r1" })
25
+ createAlertRule(db, { project_id: p.id, name: "r2" })
26
+ expect(listAlertRules(db, p.id)).toHaveLength(2)
27
+ })
28
+
29
+ it("updates a rule", () => {
30
+ const db = createTestDb()
31
+ const p = seedProject(db)
32
+ const rule = createAlertRule(db, { project_id: p.id, name: "r1" })
33
+ const updated = updateAlertRule(db, rule.id, { enabled: 0, threshold_count: 99 })
34
+ expect(updated?.enabled).toBe(0)
35
+ expect(updated?.threshold_count).toBe(99)
36
+ })
37
+
38
+ it("deletes a rule", () => {
39
+ const db = createTestDb()
40
+ const p = seedProject(db)
41
+ const rule = createAlertRule(db, { project_id: p.id, name: "r1" })
42
+ deleteAlertRule(db, rule.id)
43
+ expect(listAlertRules(db, p.id)).toHaveLength(0)
44
+ })
45
+ })
46
+
47
+ describe("alert evaluation", () => {
48
+ it("does not fire when under threshold", async () => {
49
+ const db = createTestDb()
50
+ const p = seedProject(db)
51
+ createAlertRule(db, { project_id: p.id, name: "r", level: "error", threshold_count: 10, window_seconds: 60, action: "log" })
52
+ ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "error" as const, message: "e", project_id: p.id })))
53
+ // No throw = passes
54
+ await expect(evaluateAlerts(db, p.id, null, "error")).resolves.toBeUndefined()
55
+ })
56
+
57
+ it("fires when threshold exceeded (log action)", async () => {
58
+ const db = createTestDb()
59
+ const p = seedProject(db)
60
+ createAlertRule(db, { project_id: p.id, name: "r", level: "error", threshold_count: 3, window_seconds: 3600, action: "log" })
61
+ ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "error" as const, message: "e", project_id: p.id })))
62
+ await expect(evaluateAlerts(db, p.id, null, "error")).resolves.toBeUndefined()
63
+ // Verify last_fired_at was set
64
+ const rule = db.prepare("SELECT last_fired_at FROM alert_rules WHERE project_id = ?").get(p.id) as { last_fired_at: string | null }
65
+ expect(rule.last_fired_at).toBeTruthy()
66
+ })
67
+ })