@hasna/logs 0.0.1 → 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.
- package/dashboard/README.md +73 -0
- package/dashboard/bun.lock +526 -0
- package/dashboard/eslint.config.js +23 -0
- package/dashboard/index.html +13 -0
- package/dashboard/package.json +32 -0
- package/dashboard/public/favicon.svg +1 -0
- package/dashboard/public/icons.svg +24 -0
- package/dashboard/src/App.css +184 -0
- package/dashboard/src/App.tsx +49 -0
- package/dashboard/src/api.ts +33 -0
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +1 -0
- package/dashboard/src/assets/vite.svg +1 -0
- package/dashboard/src/index.css +111 -0
- package/dashboard/src/main.tsx +10 -0
- package/dashboard/src/pages/Alerts.tsx +69 -0
- package/dashboard/src/pages/Issues.tsx +50 -0
- package/dashboard/src/pages/Perf.tsx +75 -0
- package/dashboard/src/pages/Projects.tsx +67 -0
- package/dashboard/src/pages/Summary.tsx +67 -0
- package/dashboard/src/pages/Tail.tsx +65 -0
- package/dashboard/tsconfig.app.json +28 -0
- package/dashboard/tsconfig.json +7 -0
- package/dashboard/tsconfig.node.json +26 -0
- package/dashboard/vite.config.ts +14 -0
- package/dist/cli/index.js +116 -12
- package/dist/mcp/index.js +306 -100
- package/dist/server/index.js +592 -7
- package/package.json +12 -2
- package/sdk/package.json +3 -2
- package/sdk/src/index.ts +1 -1
- package/sdk/src/types.ts +56 -0
- package/src/cli/index.ts +114 -4
- package/src/db/index.ts +10 -0
- package/src/db/migrations/001_alert_rules.ts +21 -0
- package/src/db/migrations/002_issues.ts +21 -0
- package/src/db/migrations/003_retention.ts +15 -0
- package/src/db/migrations/004_page_auth.ts +13 -0
- package/src/lib/alerts.test.ts +67 -0
- package/src/lib/alerts.ts +117 -0
- package/src/lib/compare.test.ts +52 -0
- package/src/lib/compare.ts +85 -0
- package/src/lib/diagnose.test.ts +55 -0
- package/src/lib/diagnose.ts +76 -0
- package/src/lib/export.test.ts +66 -0
- package/src/lib/export.ts +65 -0
- package/src/lib/health.test.ts +48 -0
- package/src/lib/health.ts +51 -0
- package/src/lib/ingest.ts +25 -2
- package/src/lib/issues.test.ts +79 -0
- package/src/lib/issues.ts +70 -0
- package/src/lib/page-auth.test.ts +54 -0
- package/src/lib/page-auth.ts +48 -0
- package/src/lib/retention.test.ts +42 -0
- package/src/lib/retention.ts +62 -0
- package/src/lib/scanner.ts +21 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/session-context.ts +28 -0
- package/src/mcp/index.ts +133 -89
- package/src/server/index.ts +12 -1
- package/src/server/routes/alerts.ts +32 -0
- package/src/server/routes/issues.ts +43 -0
- package/src/server/routes/logs.ts +21 -0
- package/src/server/routes/projects.ts +25 -0
- package/src/server/routes/stream.ts +43 -0
package/sdk/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/logs-sdk",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Zero-dependency fetch client for @hasna/logs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-dependency fetch client for @hasna/logs — push logs, query, browse issues, perf snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "bun build src/index.ts --outdir dist --target browser"
|
|
18
18
|
},
|
|
19
|
+
"keywords": ["logs", "monitoring", "sdk", "ai-agents"],
|
|
19
20
|
"author": "Andrei Hasna <andrei@hasna.com>",
|
|
20
21
|
"license": "MIT"
|
|
21
22
|
}
|
package/sdk/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob } from "
|
|
1
|
+
import type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob } from "./types.ts"
|
|
2
2
|
|
|
3
3
|
export type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob }
|
|
4
4
|
|
package/sdk/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"
|
|
2
|
+
export type LogSource = "sdk" | "script" | "scanner"
|
|
3
|
+
|
|
4
|
+
export interface LogEntry {
|
|
5
|
+
level: LogLevel
|
|
6
|
+
message: string
|
|
7
|
+
project_id?: string
|
|
8
|
+
page_id?: string
|
|
9
|
+
source?: LogSource
|
|
10
|
+
service?: string
|
|
11
|
+
trace_id?: string
|
|
12
|
+
session_id?: string
|
|
13
|
+
agent?: string
|
|
14
|
+
url?: string
|
|
15
|
+
stack_trace?: string
|
|
16
|
+
metadata?: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LogRow {
|
|
20
|
+
id: string; timestamp: string; project_id: string | null; page_id: string | null
|
|
21
|
+
level: LogLevel; source: LogSource; service: string | null; message: string
|
|
22
|
+
trace_id: string | null; session_id: string | null; agent: string | null
|
|
23
|
+
url: string | null; stack_trace: string | null; metadata: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Project {
|
|
27
|
+
id: string; name: string; github_repo: string | null; base_url: string | null
|
|
28
|
+
description: string | null; created_at: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Page {
|
|
32
|
+
id: string; project_id: string; url: string; path: string
|
|
33
|
+
name: string | null; last_scanned_at: string | null; created_at: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ScanJob {
|
|
37
|
+
id: string; project_id: string; page_id: string | null
|
|
38
|
+
schedule: string; enabled: number; last_run_at: string | null; created_at: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PerformanceSnapshot {
|
|
42
|
+
id: string; timestamp: string; project_id: string; page_id: string | null
|
|
43
|
+
url: string; lcp: number | null; fcp: number | null; cls: number | null
|
|
44
|
+
tti: number | null; ttfb: number | null; score: number | null; raw_audit: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LogQuery {
|
|
48
|
+
project_id?: string; page_id?: string; level?: LogLevel | LogLevel[]
|
|
49
|
+
service?: string; since?: string; until?: string; text?: string
|
|
50
|
+
trace_id?: string; limit?: number; offset?: number; fields?: string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LogSummary {
|
|
54
|
+
project_id: string | null; service: string | null; page_id: string | null
|
|
55
|
+
level: LogLevel; count: number; latest: string
|
|
56
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -151,6 +151,102 @@ program.command("scan")
|
|
|
151
151
|
console.log("Scan complete.")
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
// ── logs watch ────────────────────────────────────────────
|
|
155
|
+
program.command("watch")
|
|
156
|
+
.description("Stream new logs in real time with color coding")
|
|
157
|
+
.option("--project <id>")
|
|
158
|
+
.option("--level <levels>", "Comma-separated levels")
|
|
159
|
+
.option("--service <name>")
|
|
160
|
+
.action(async (opts) => {
|
|
161
|
+
const db = getDb()
|
|
162
|
+
const { searchLogs } = await import("../lib/query.ts")
|
|
163
|
+
|
|
164
|
+
const COLORS: Record<string, string> = {
|
|
165
|
+
debug: "\x1b[90m", info: "\x1b[36m", warn: "\x1b[33m", error: "\x1b[31m", fatal: "\x1b[35m",
|
|
166
|
+
}
|
|
167
|
+
const RESET = "\x1b[0m"
|
|
168
|
+
const BOLD = "\x1b[1m"
|
|
169
|
+
|
|
170
|
+
let lastTimestamp = new Date().toISOString()
|
|
171
|
+
let errorCount = 0
|
|
172
|
+
let warnCount = 0
|
|
173
|
+
|
|
174
|
+
process.stdout.write(`\x1b[2J\x1b[H`) // clear screen
|
|
175
|
+
console.log(`${BOLD}@hasna/logs watch${RESET} — Ctrl+C to exit\n`)
|
|
176
|
+
|
|
177
|
+
const poll = () => {
|
|
178
|
+
const rows = searchLogs(db, {
|
|
179
|
+
project_id: opts.project,
|
|
180
|
+
level: opts.level ? (opts.level.split(",") as LogLevel[]) : undefined,
|
|
181
|
+
service: opts.service,
|
|
182
|
+
since: lastTimestamp,
|
|
183
|
+
limit: 100,
|
|
184
|
+
}).reverse()
|
|
185
|
+
|
|
186
|
+
for (const row of rows) {
|
|
187
|
+
if (row.timestamp <= lastTimestamp) continue
|
|
188
|
+
lastTimestamp = row.timestamp
|
|
189
|
+
if (row.level === "error" || row.level === "fatal") errorCount++
|
|
190
|
+
if (row.level === "warn") warnCount++
|
|
191
|
+
const color = COLORS[row.level] ?? ""
|
|
192
|
+
const ts = row.timestamp.slice(11, 19)
|
|
193
|
+
const svc = (row.service ?? "-").padEnd(12)
|
|
194
|
+
const lvl = row.level.toUpperCase().padEnd(5)
|
|
195
|
+
console.log(`${color}${ts} ${BOLD}${lvl}${RESET}${color} ${svc} ${row.message}${RESET}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Update terminal title with counts
|
|
199
|
+
process.stdout.write(`\x1b]2;logs: ${errorCount}E ${warnCount}W\x07`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const interval = setInterval(poll, 500)
|
|
203
|
+
process.on("SIGINT", () => { clearInterval(interval); console.log(`\n\nErrors: ${errorCount} Warnings: ${warnCount}`); process.exit(0) })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ── logs export ───────────────────────────────────────────
|
|
207
|
+
program.command("export")
|
|
208
|
+
.description("Export logs to JSON or CSV")
|
|
209
|
+
.option("--project <id>")
|
|
210
|
+
.option("--since <time>", "Relative time or ISO")
|
|
211
|
+
.option("--level <level>")
|
|
212
|
+
.option("--service <name>")
|
|
213
|
+
.option("--format <fmt>", "json or csv", "json")
|
|
214
|
+
.option("--output <file>", "Output file (default: stdout)")
|
|
215
|
+
.option("--limit <n>", "Max rows", "100000")
|
|
216
|
+
.action(async (opts) => {
|
|
217
|
+
const { exportToCsv, exportToJson } = await import("../lib/export.ts")
|
|
218
|
+
const { createWriteStream } = await import("node:fs")
|
|
219
|
+
const db = getDb()
|
|
220
|
+
const options = {
|
|
221
|
+
project_id: opts.project,
|
|
222
|
+
since: parseRelativeTime(opts.since),
|
|
223
|
+
level: opts.level,
|
|
224
|
+
service: opts.service,
|
|
225
|
+
limit: Number(opts.limit),
|
|
226
|
+
}
|
|
227
|
+
let count = 0
|
|
228
|
+
if (opts.output) {
|
|
229
|
+
const stream = createWriteStream(opts.output)
|
|
230
|
+
const write = (s: string) => stream.write(s)
|
|
231
|
+
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
232
|
+
stream.end()
|
|
233
|
+
console.error(`Exported ${count} log(s) to ${opts.output}`)
|
|
234
|
+
} else {
|
|
235
|
+
const write = (s: string) => process.stdout.write(s)
|
|
236
|
+
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
237
|
+
process.stderr.write(`\nExported ${count} log(s)\n`)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ── logs health ───────────────────────────────────────────
|
|
242
|
+
program.command("health")
|
|
243
|
+
.description("Show server health and DB stats")
|
|
244
|
+
.action(async () => {
|
|
245
|
+
const { getHealth } = await import("../lib/health.ts")
|
|
246
|
+
const h = getHealth(getDb())
|
|
247
|
+
console.log(JSON.stringify(h, null, 2))
|
|
248
|
+
})
|
|
249
|
+
|
|
154
250
|
// ── logs mcp / logs serve ─────────────────────────────────
|
|
155
251
|
program.command("mcp")
|
|
156
252
|
.description("Start the MCP server")
|
|
@@ -159,11 +255,25 @@ program.command("mcp")
|
|
|
159
255
|
.option("--gemini", "Install into Gemini")
|
|
160
256
|
.action(async (opts) => {
|
|
161
257
|
if (opts.claude || opts.codex || opts.gemini) {
|
|
162
|
-
const
|
|
163
|
-
|
|
258
|
+
const { execSync } = await import("node:child_process")
|
|
259
|
+
// Resolve the MCP binary path — works from both source and dist
|
|
260
|
+
const selfPath = process.argv[1] ?? new URL(import.meta.url).pathname
|
|
261
|
+
const mcpBin = selfPath.replace(/cli\/index\.(ts|js)$/, "mcp/index.$1")
|
|
262
|
+
const runtime = process.execPath // bun or node
|
|
263
|
+
|
|
164
264
|
if (opts.claude) {
|
|
165
|
-
const
|
|
166
|
-
|
|
265
|
+
const cmd = `claude mcp add --transport stdio --scope user logs -- ${runtime} ${mcpBin}`
|
|
266
|
+
console.log(`Running: ${cmd}`)
|
|
267
|
+
execSync(cmd, { stdio: "inherit" })
|
|
268
|
+
console.log("✓ Installed logs-mcp into Claude Code")
|
|
269
|
+
}
|
|
270
|
+
if (opts.codex) {
|
|
271
|
+
const config = `[mcp_servers.logs]\ncommand = "${runtime}"\nargs = ["${mcpBin}"]`
|
|
272
|
+
console.log("Add to ~/.codex/config.toml:\n\n" + config)
|
|
273
|
+
}
|
|
274
|
+
if (opts.gemini) {
|
|
275
|
+
const config = JSON.stringify({ mcpServers: { logs: { command: runtime, args: [mcpBin] } } }, null, 2)
|
|
276
|
+
console.log("Add to ~/.gemini/settings.json mcpServers:\n\n" + config)
|
|
167
277
|
}
|
|
168
278
|
return
|
|
169
279
|
}
|
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
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export interface AlertRule {
|
|
4
|
+
id: string
|
|
5
|
+
project_id: string
|
|
6
|
+
name: string
|
|
7
|
+
service: string | null
|
|
8
|
+
level: string
|
|
9
|
+
threshold_count: number
|
|
10
|
+
window_seconds: number
|
|
11
|
+
action: "webhook" | "log"
|
|
12
|
+
webhook_url: string | null
|
|
13
|
+
enabled: number
|
|
14
|
+
last_fired_at: string | null
|
|
15
|
+
created_at: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createAlertRule(db: Database, data: {
|
|
19
|
+
project_id: string
|
|
20
|
+
name: string
|
|
21
|
+
service?: string
|
|
22
|
+
level?: string
|
|
23
|
+
threshold_count?: number
|
|
24
|
+
window_seconds?: number
|
|
25
|
+
action?: "webhook" | "log"
|
|
26
|
+
webhook_url?: string
|
|
27
|
+
}): AlertRule {
|
|
28
|
+
return db.prepare(`
|
|
29
|
+
INSERT INTO alert_rules (project_id, name, service, level, threshold_count, window_seconds, action, webhook_url)
|
|
30
|
+
VALUES ($project_id, $name, $service, $level, $threshold_count, $window_seconds, $action, $webhook_url)
|
|
31
|
+
RETURNING *
|
|
32
|
+
`).get({
|
|
33
|
+
$project_id: data.project_id,
|
|
34
|
+
$name: data.name,
|
|
35
|
+
$service: data.service ?? null,
|
|
36
|
+
$level: data.level ?? "error",
|
|
37
|
+
$threshold_count: data.threshold_count ?? 10,
|
|
38
|
+
$window_seconds: data.window_seconds ?? 60,
|
|
39
|
+
$action: data.action ?? "webhook",
|
|
40
|
+
$webhook_url: data.webhook_url ?? null,
|
|
41
|
+
}) as AlertRule
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listAlertRules(db: Database, projectId?: string): AlertRule[] {
|
|
45
|
+
if (projectId) {
|
|
46
|
+
return db.prepare("SELECT * FROM alert_rules WHERE project_id = $p ORDER BY created_at DESC").all({ $p: projectId }) as AlertRule[]
|
|
47
|
+
}
|
|
48
|
+
return db.prepare("SELECT * FROM alert_rules ORDER BY created_at DESC").all() as AlertRule[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updateAlertRule(db: Database, id: string, data: Partial<Pick<AlertRule, "enabled" | "threshold_count" | "window_seconds" | "webhook_url">>): AlertRule | null {
|
|
52
|
+
const fields = Object.keys(data).map(k => `${k} = $${k}`).join(", ")
|
|
53
|
+
if (!fields) return db.prepare("SELECT * FROM alert_rules WHERE id = $id").get({ $id: id }) as AlertRule | null
|
|
54
|
+
const params = Object.fromEntries(Object.entries(data).map(([k, v]) => [`$${k}`, v]))
|
|
55
|
+
params.$id = id
|
|
56
|
+
return db.prepare(`UPDATE alert_rules SET ${fields} WHERE id = $id RETURNING *`).get(params) as AlertRule | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function deleteAlertRule(db: Database, id: string): void {
|
|
60
|
+
db.run("DELETE FROM alert_rules WHERE id = $id", { $id: id })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function evaluateAlerts(db: Database, projectId: string, service: string | null, level: string): Promise<void> {
|
|
64
|
+
const rules = db.prepare(`
|
|
65
|
+
SELECT * FROM alert_rules
|
|
66
|
+
WHERE project_id = $p AND level = $level AND enabled = 1
|
|
67
|
+
AND ($service IS NULL OR service IS NULL OR service = $service)
|
|
68
|
+
`).all({ $p: projectId, $level: level, $service: service }) as AlertRule[]
|
|
69
|
+
|
|
70
|
+
for (const rule of rules) {
|
|
71
|
+
const since = new Date(Date.now() - rule.window_seconds * 1000).toISOString()
|
|
72
|
+
const conditions = ["project_id = $p", "level = $level", "timestamp >= $since"]
|
|
73
|
+
const params: Record<string, unknown> = { $p: projectId, $level: rule.level, $since: since }
|
|
74
|
+
if (rule.service) { conditions.push("service = $service"); params.$service = rule.service }
|
|
75
|
+
|
|
76
|
+
const { count } = db.prepare(`SELECT COUNT(*) as count FROM logs WHERE ${conditions.join(" AND ")}`).get(params) as { count: number }
|
|
77
|
+
|
|
78
|
+
if (count >= rule.threshold_count) {
|
|
79
|
+
await fireAlert(db, rule, count)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fireAlert(db: Database, rule: AlertRule, count: number): Promise<void> {
|
|
85
|
+
// Debounce: don't fire more than once per window
|
|
86
|
+
if (rule.last_fired_at) {
|
|
87
|
+
const lastFired = new Date(rule.last_fired_at).getTime()
|
|
88
|
+
if (Date.now() - lastFired < rule.window_seconds * 1000) return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
db.run("UPDATE alert_rules SET last_fired_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = $id", { $id: rule.id })
|
|
92
|
+
|
|
93
|
+
const payload = {
|
|
94
|
+
alert: rule.name,
|
|
95
|
+
project_id: rule.project_id,
|
|
96
|
+
level: rule.level,
|
|
97
|
+
service: rule.service,
|
|
98
|
+
count,
|
|
99
|
+
threshold: rule.threshold_count,
|
|
100
|
+
window_seconds: rule.window_seconds,
|
|
101
|
+
fired_at: new Date().toISOString(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (rule.action === "webhook" && rule.webhook_url) {
|
|
105
|
+
try {
|
|
106
|
+
await fetch(rule.webhook_url, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
})
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`Alert webhook failed for rule ${rule.id}:`, err)
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.warn(`[ALERT] ${rule.name}:`, JSON.stringify(payload))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createTestDb } from "../db/index.ts"
|
|
3
|
+
import { ingestBatch } from "./ingest.ts"
|
|
4
|
+
import { compare } from "./compare.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("compare", () => {
|
|
11
|
+
it("detects new errors in window B", () => {
|
|
12
|
+
const db = createTestDb()
|
|
13
|
+
const p = seedProject(db)
|
|
14
|
+
const dayAgo = new Date(Date.now() - 48 * 3600 * 1000).toISOString()
|
|
15
|
+
const halfDayAgo = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
|
|
16
|
+
const now = new Date().toISOString()
|
|
17
|
+
|
|
18
|
+
// Window A: old error
|
|
19
|
+
db.prepare("INSERT INTO logs (project_id, level, message, service, timestamp) VALUES (?, 'error', 'old bug', 'api', ?)").run(p.id, dayAgo)
|
|
20
|
+
// Window B: new error
|
|
21
|
+
db.prepare("INSERT INTO logs (project_id, level, message, service, timestamp) VALUES (?, 'error', 'new bug', 'api', ?)").run(p.id, now)
|
|
22
|
+
|
|
23
|
+
const result = compare(db, p.id, dayAgo, halfDayAgo, halfDayAgo, now)
|
|
24
|
+
expect(result.new_errors.some(e => e.message === "new bug")).toBe(true)
|
|
25
|
+
expect(result.resolved_errors.some(e => e.message === "old bug")).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns empty diff when no changes", () => {
|
|
29
|
+
const db = createTestDb()
|
|
30
|
+
const p = seedProject(db)
|
|
31
|
+
const since = new Date(Date.now() - 48 * 3600 * 1000).toISOString()
|
|
32
|
+
const mid = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
|
|
33
|
+
const now = new Date().toISOString()
|
|
34
|
+
const result = compare(db, p.id, since, mid, mid, now)
|
|
35
|
+
expect(result.new_errors).toHaveLength(0)
|
|
36
|
+
expect(result.resolved_errors).toHaveLength(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("has correct structure", () => {
|
|
40
|
+
const db = createTestDb()
|
|
41
|
+
const p = seedProject(db)
|
|
42
|
+
const since = new Date(Date.now() - 48 * 3600 * 1000).toISOString()
|
|
43
|
+
const mid = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
|
|
44
|
+
const now = new Date().toISOString()
|
|
45
|
+
const result = compare(db, p.id, since, mid, mid, now)
|
|
46
|
+
expect(result).toHaveProperty("project_id")
|
|
47
|
+
expect(result).toHaveProperty("new_errors")
|
|
48
|
+
expect(result).toHaveProperty("resolved_errors")
|
|
49
|
+
expect(result).toHaveProperty("error_delta_by_service")
|
|
50
|
+
expect(result).toHaveProperty("summary")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export interface CompareResult {
|
|
4
|
+
project_id: string
|
|
5
|
+
window_a: { since: string; until: string }
|
|
6
|
+
window_b: { since: string; until: string }
|
|
7
|
+
new_errors: { message: string; service: string | null; count: number }[]
|
|
8
|
+
resolved_errors: { message: string; service: string | null; count: number }[]
|
|
9
|
+
error_delta_by_service: { service: string | null; errors_a: number; errors_b: number; delta: number }[]
|
|
10
|
+
perf_delta_by_page: { page_id: string; url: string; score_a: number | null; score_b: number | null; delta: number | null }[]
|
|
11
|
+
summary: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getErrorsByMessage(db: Database, projectId: string, since: string, until: string) {
|
|
15
|
+
return db.prepare(`
|
|
16
|
+
SELECT message, service, COUNT(*) as count
|
|
17
|
+
FROM logs
|
|
18
|
+
WHERE project_id = $p AND level IN ('error','fatal') AND timestamp >= $since AND timestamp <= $until
|
|
19
|
+
GROUP BY message, service
|
|
20
|
+
`).all({ $p: projectId, $since: since, $until: until }) as { message: string; service: string | null; count: number }[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getErrorsByService(db: Database, projectId: string, since: string, until: string) {
|
|
24
|
+
return db.prepare(`
|
|
25
|
+
SELECT service, COUNT(*) as errors
|
|
26
|
+
FROM logs
|
|
27
|
+
WHERE project_id = $p AND level IN ('error','fatal') AND timestamp >= $since AND timestamp <= $until
|
|
28
|
+
GROUP BY service
|
|
29
|
+
`).all({ $p: projectId, $since: since, $until: until }) as { service: string | null; errors: number }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function compare(
|
|
33
|
+
db: Database,
|
|
34
|
+
projectId: string,
|
|
35
|
+
aSince: string, aUntil: string,
|
|
36
|
+
bSince: string, bUntil: string,
|
|
37
|
+
): CompareResult {
|
|
38
|
+
const errorsA = getErrorsByMessage(db, projectId, aSince, aUntil)
|
|
39
|
+
const errorsB = getErrorsByMessage(db, projectId, bSince, bUntil)
|
|
40
|
+
|
|
41
|
+
const keyA = new Set(errorsA.map(e => `${e.service}|${e.message}`))
|
|
42
|
+
const keyB = new Set(errorsB.map(e => `${e.service}|${e.message}`))
|
|
43
|
+
|
|
44
|
+
const new_errors = errorsB.filter(e => !keyA.has(`${e.service}|${e.message}`))
|
|
45
|
+
const resolved_errors = errorsA.filter(e => !keyB.has(`${e.service}|${e.message}`))
|
|
46
|
+
|
|
47
|
+
// Service-level delta
|
|
48
|
+
const svcA = getErrorsByService(db, projectId, aSince, aUntil)
|
|
49
|
+
const svcB = getErrorsByService(db, projectId, bSince, bUntil)
|
|
50
|
+
const svcMapA = new Map(svcA.map(s => [s.service, s.errors]))
|
|
51
|
+
const svcMapB = new Map(svcB.map(s => [s.service, s.errors]))
|
|
52
|
+
const allSvcs = new Set([...svcMapA.keys(), ...svcMapB.keys()])
|
|
53
|
+
const error_delta_by_service = [...allSvcs].map(svc => ({
|
|
54
|
+
service: svc,
|
|
55
|
+
errors_a: svcMapA.get(svc) ?? 0,
|
|
56
|
+
errors_b: svcMapB.get(svc) ?? 0,
|
|
57
|
+
delta: (svcMapB.get(svc) ?? 0) - (svcMapA.get(svc) ?? 0),
|
|
58
|
+
})).sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
|
59
|
+
|
|
60
|
+
// Perf delta per page
|
|
61
|
+
const perf_delta_by_page = db.prepare(`
|
|
62
|
+
SELECT
|
|
63
|
+
pa.page_id, pg.url,
|
|
64
|
+
pa.score as score_a,
|
|
65
|
+
pb.score as score_b,
|
|
66
|
+
(pb.score - pa.score) as delta
|
|
67
|
+
FROM
|
|
68
|
+
(SELECT page_id, AVG(score) as score FROM performance_snapshots WHERE project_id = $p AND timestamp >= $as AND timestamp <= $au GROUP BY page_id) pa
|
|
69
|
+
JOIN pages pg ON pg.id = pa.page_id
|
|
70
|
+
LEFT JOIN (SELECT page_id, AVG(score) as score FROM performance_snapshots WHERE project_id = $p AND timestamp >= $bs AND timestamp <= $bu GROUP BY page_id) pb ON pb.page_id = pa.page_id
|
|
71
|
+
ORDER BY delta ASC
|
|
72
|
+
`).all({ $p: projectId, $as: aSince, $au: aUntil, $bs: bSince, $bu: bUntil }) as CompareResult["perf_delta_by_page"]
|
|
73
|
+
|
|
74
|
+
const summary = [
|
|
75
|
+
`${new_errors.length} new error type(s), ${resolved_errors.length} resolved.`,
|
|
76
|
+
error_delta_by_service.filter(s => s.delta > 0).map(s => `${s.service ?? "unknown"}: +${s.delta}`).join(", ") || "No error increases.",
|
|
77
|
+
].join(" ")
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
project_id: projectId,
|
|
81
|
+
window_a: { since: aSince, until: aUntil },
|
|
82
|
+
window_b: { since: bSince, until: bUntil },
|
|
83
|
+
new_errors, resolved_errors, error_delta_by_service, perf_delta_by_page, summary,
|
|
84
|
+
}
|
|
85
|
+
}
|