@hasna/logs 0.3.26 → 0.3.28
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/README.md +33 -10
- package/dashboard/dist/assets/index-C0wZYq1m.js +53 -0
- package/dashboard/dist/assets/index-DGNrK5qb.css +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/cli/index.js +8511 -177
- package/dist/count-bmj4r2zb.js +10 -0
- package/dist/{diagnose-e0w5rwbc.js → diagnose-3q5cy9ra.js} +2 -2
- package/dist/{export-c3eqjste.js → export-cngdb9fh.js} +1 -1
- package/dist/{http-zm3ph78w.js → http-r0xc3d2s.js} +79 -8
- package/dist/index-931pbyn5.js +141 -0
- package/dist/index-b5c72f1p.js +7 -0
- package/dist/{index-7w7v7hnr.js → index-by1pdzbr.js} +14 -5
- package/dist/{index-3dr7d80h.js → index-e1930v9b.js} +12 -8
- package/dist/{index-eh9bkbpa.js → index-e72k53yq.js} +10 -2
- package/dist/{index-edn08m6f.js → index-gcd14q2f.js} +9 -6
- package/dist/index-hq6kzaah.js +26 -0
- package/dist/index-j34f36wy.js +5672 -0
- package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
- package/dist/index-qk8dbvbc.js +1859 -0
- package/dist/index-t3x838zw.js +2583 -0
- package/dist/{index-gc0zvs88.js → index-y2y0mdtd.js} +596 -37
- package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
- package/dist/index.js +2990 -22
- package/dist/{jobs-ypmmc2ma.js → jobs-hsgyhfvm.js} +2 -1
- package/dist/mcp/index.js +1473 -4286
- package/dist/{query-7jwj05er.js → query-c5a43zx3.js} +3 -2
- package/dist/server/index.js +2944 -417
- package/dist/storage.js +50 -0
- package/package.json +27 -8
- package/biome.json +0 -13
- package/bun.lock +0 -376
- package/dashboard/README.md +0 -73
- package/dashboard/bun.lock +0 -526
- package/dashboard/eslint.config.js +0 -23
- package/dashboard/index.html +0 -13
- package/dashboard/package.json +0 -32
- package/dashboard/src/App.css +0 -184
- package/dashboard/src/App.tsx +0 -49
- package/dashboard/src/api.ts +0 -33
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +0 -1
- package/dashboard/src/assets/vite.svg +0 -1
- package/dashboard/src/index.css +0 -111
- package/dashboard/src/main.tsx +0 -10
- package/dashboard/src/pages/Alerts.tsx +0 -69
- package/dashboard/src/pages/Issues.tsx +0 -50
- package/dashboard/src/pages/Perf.tsx +0 -75
- package/dashboard/src/pages/Projects.tsx +0 -67
- package/dashboard/src/pages/Summary.tsx +0 -67
- package/dashboard/src/pages/Tail.tsx +0 -65
- package/dashboard/tsconfig.app.json +0 -28
- package/dashboard/tsconfig.json +0 -7
- package/dashboard/tsconfig.node.json +0 -26
- package/dashboard/vite.config.ts +0 -14
- package/dist/count-x3n7qg3c.js +0 -9
- package/dist/index-997bkzr2.js +0 -15
- package/dist/index-pen6t0yc.js +0 -10794
- package/sdk/package.json +0 -27
- package/sdk/src/index.ts +0 -143
- package/sdk/src/types.ts +0 -56
- package/src/cli/entrypoints.test.ts +0 -63
- package/src/cli/index.ts +0 -471
- package/src/db/index.test.ts +0 -33
- package/src/db/index.ts +0 -189
- package/src/db/migrations/001_alert_rules.ts +0 -21
- package/src/db/migrations/002_issues.ts +0 -21
- package/src/db/migrations/003_retention.ts +0 -15
- package/src/db/migrations/004_page_auth.ts +0 -13
- package/src/db/pg-migrations.ts +0 -167
- package/src/index.ts +0 -1
- package/src/lib/alerts.test.ts +0 -67
- package/src/lib/alerts.ts +0 -117
- package/src/lib/browser-script.test.ts +0 -35
- package/src/lib/browser-script.ts +0 -31
- package/src/lib/compare.test.ts +0 -52
- package/src/lib/compare.ts +0 -85
- package/src/lib/count.test.ts +0 -44
- package/src/lib/count.ts +0 -55
- package/src/lib/diagnose.test.ts +0 -55
- package/src/lib/diagnose.ts +0 -91
- package/src/lib/export.test.ts +0 -66
- package/src/lib/export.ts +0 -65
- package/src/lib/github.ts +0 -38
- package/src/lib/health.test.ts +0 -48
- package/src/lib/health.ts +0 -51
- package/src/lib/ingest.test.ts +0 -57
- package/src/lib/ingest.ts +0 -78
- package/src/lib/issues.test.ts +0 -79
- package/src/lib/issues.ts +0 -70
- package/src/lib/jobs.test.ts +0 -69
- package/src/lib/jobs.ts +0 -63
- package/src/lib/lighthouse.ts +0 -65
- package/src/lib/package-meta.test.ts +0 -43
- package/src/lib/package-meta.ts +0 -80
- package/src/lib/page-auth.test.ts +0 -54
- package/src/lib/page-auth.ts +0 -48
- package/src/lib/parse-time.test.ts +0 -37
- package/src/lib/parse-time.ts +0 -14
- package/src/lib/perf.test.ts +0 -45
- package/src/lib/perf.ts +0 -46
- package/src/lib/projects.test.ts +0 -73
- package/src/lib/projects.ts +0 -69
- package/src/lib/query.test.ts +0 -104
- package/src/lib/query.ts +0 -84
- package/src/lib/retention.test.ts +0 -42
- package/src/lib/retention.ts +0 -62
- package/src/lib/rotate.test.ts +0 -37
- package/src/lib/rotate.ts +0 -27
- package/src/lib/scanner.ts +0 -131
- package/src/lib/scheduler.ts +0 -63
- package/src/lib/session-context.ts +0 -28
- package/src/lib/summarize.test.ts +0 -38
- package/src/lib/summarize.ts +0 -23
- package/src/mcp/http.test.ts +0 -92
- package/src/mcp/http.ts +0 -135
- package/src/mcp/index.test.ts +0 -27
- package/src/mcp/index.ts +0 -444
- package/src/server/index.ts +0 -61
- package/src/server/routes/alerts.ts +0 -32
- package/src/server/routes/issues.ts +0 -43
- package/src/server/routes/jobs.ts +0 -32
- package/src/server/routes/logs.ts +0 -113
- package/src/server/routes/perf.ts +0 -23
- package/src/server/routes/projects.ts +0 -67
- package/src/server/routes/stream.ts +0 -43
- package/src/server/server.test.ts +0 -194
- package/src/types/index.ts +0 -119
- package/tsconfig.json +0 -22
- /package/dashboard/{public → dist}/favicon.svg +0 -0
- /package/dashboard/{public → dist}/icons.svg +0 -0
package/src/lib/compare.test.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
})
|
package/src/lib/compare.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
}
|
package/src/lib/count.test.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch } from "./ingest.ts"
|
|
4
|
-
import { countLogs } from "./count.ts"
|
|
5
|
-
|
|
6
|
-
describe("countLogs", () => {
|
|
7
|
-
it("counts all logs", () => {
|
|
8
|
-
const db = createTestDb()
|
|
9
|
-
ingestBatch(db, [{ level: "error", message: "e" }, { level: "warn", message: "w" }, { level: "info", message: "i" }])
|
|
10
|
-
const c = countLogs(db, {})
|
|
11
|
-
expect(c.total).toBe(3)
|
|
12
|
-
expect(c.errors).toBe(1)
|
|
13
|
-
expect(c.warns).toBe(1)
|
|
14
|
-
expect(c.fatals).toBe(0)
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it("filters by project", () => {
|
|
18
|
-
const db = createTestDb()
|
|
19
|
-
const p = db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
|
|
20
|
-
ingestBatch(db, [{ level: "error", message: "e", project_id: p.id }, { level: "error", message: "e2" }])
|
|
21
|
-
const c = countLogs(db, { project_id: p.id })
|
|
22
|
-
expect(c.total).toBe(1)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it("filters by service", () => {
|
|
26
|
-
const db = createTestDb()
|
|
27
|
-
ingestBatch(db, [{ level: "error", message: "e", service: "api" }, { level: "error", message: "e2", service: "db" }])
|
|
28
|
-
expect(countLogs(db, { service: "api" }).total).toBe(1)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it("returns zero counts for empty db", () => {
|
|
32
|
-
const c = countLogs(createTestDb(), {})
|
|
33
|
-
expect(c.total).toBe(0)
|
|
34
|
-
expect(c.errors).toBe(0)
|
|
35
|
-
expect(c.by_level).toEqual({})
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it("accepts relative since", () => {
|
|
39
|
-
const db = createTestDb()
|
|
40
|
-
ingestBatch(db, [{ level: "error", message: "recent" }])
|
|
41
|
-
const c = countLogs(db, { since: "1h" })
|
|
42
|
-
expect(c.total).toBe(1)
|
|
43
|
-
})
|
|
44
|
-
})
|
package/src/lib/count.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import { parseTime } from "./parse-time.ts"
|
|
3
|
-
|
|
4
|
-
export interface LogCount {
|
|
5
|
-
total: number
|
|
6
|
-
errors: number
|
|
7
|
-
warns: number
|
|
8
|
-
fatals: number
|
|
9
|
-
by_level: Record<string, number>
|
|
10
|
-
by_service?: Record<string, number>
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function countLogs(db: Database, opts: {
|
|
14
|
-
project_id?: string
|
|
15
|
-
service?: string
|
|
16
|
-
level?: string
|
|
17
|
-
since?: string
|
|
18
|
-
until?: string
|
|
19
|
-
group_by?: "level" | "service"
|
|
20
|
-
}): LogCount {
|
|
21
|
-
const conditions: string[] = []
|
|
22
|
-
const params: Record<string, unknown> = {}
|
|
23
|
-
|
|
24
|
-
if (opts.project_id) { conditions.push("project_id = $p"); params.$p = opts.project_id }
|
|
25
|
-
if (opts.service) { conditions.push("service = $service"); params.$service = opts.service }
|
|
26
|
-
if (opts.level) { conditions.push("level = $level"); params.$level = opts.level }
|
|
27
|
-
const since = parseTime(opts.since)
|
|
28
|
-
const until = parseTime(opts.until)
|
|
29
|
-
if (since) { conditions.push("timestamp >= $since"); params.$since = since }
|
|
30
|
-
if (until) { conditions.push("timestamp <= $until"); params.$until = until }
|
|
31
|
-
|
|
32
|
-
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
33
|
-
|
|
34
|
-
const byLevel = db.prepare(`SELECT level, COUNT(*) as c FROM logs ${where} GROUP BY level`)
|
|
35
|
-
.all(params) as { level: string; c: number }[]
|
|
36
|
-
|
|
37
|
-
const by_level = Object.fromEntries(byLevel.map(r => [r.level, r.c]))
|
|
38
|
-
const total = byLevel.reduce((s, r) => s + r.c, 0)
|
|
39
|
-
|
|
40
|
-
let by_service: Record<string, number> | undefined
|
|
41
|
-
if (opts.group_by === "service") {
|
|
42
|
-
const bySvc = db.prepare(`SELECT COALESCE(service, '-') as service, COUNT(*) as c FROM logs ${where} GROUP BY service ORDER BY c DESC`)
|
|
43
|
-
.all(params) as { service: string; c: number }[]
|
|
44
|
-
by_service = Object.fromEntries(bySvc.map(r => [r.service, r.c]))
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
total,
|
|
49
|
-
errors: by_level["error"] ?? 0,
|
|
50
|
-
warns: by_level["warn"] ?? 0,
|
|
51
|
-
fatals: by_level["fatal"] ?? 0,
|
|
52
|
-
by_level,
|
|
53
|
-
by_service,
|
|
54
|
-
}
|
|
55
|
-
}
|
package/src/lib/diagnose.test.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch } from "./ingest.ts"
|
|
4
|
-
import { diagnose } from "./diagnose.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("diagnose", () => {
|
|
11
|
-
it("returns empty diagnosis for project with no logs", () => {
|
|
12
|
-
const db = createTestDb()
|
|
13
|
-
const p = seedProject(db)
|
|
14
|
-
const result = diagnose(db, p.id)
|
|
15
|
-
expect(result.project_id).toBe(p.id)
|
|
16
|
-
expect(result.top_errors).toHaveLength(0)
|
|
17
|
-
expect(result.summary).toContain("No errors")
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it("surfaces top errors", () => {
|
|
21
|
-
const db = createTestDb()
|
|
22
|
-
const p = seedProject(db)
|
|
23
|
-
ingestBatch(db, [
|
|
24
|
-
{ level: "error", message: "DB timeout", service: "api", project_id: p.id },
|
|
25
|
-
{ level: "error", message: "DB timeout", service: "api", project_id: p.id },
|
|
26
|
-
{ level: "error", message: "Auth failed", service: "auth", project_id: p.id },
|
|
27
|
-
])
|
|
28
|
-
const result = diagnose(db, p.id)
|
|
29
|
-
expect(result.top_errors.length).toBeGreaterThan(0)
|
|
30
|
-
expect(result.top_errors[0]!.message).toBe("DB timeout")
|
|
31
|
-
expect(result.top_errors[0]!.count).toBe(2)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it("populates summary with error info", () => {
|
|
35
|
-
const db = createTestDb()
|
|
36
|
-
const p = seedProject(db)
|
|
37
|
-
ingestBatch(db, [{ level: "error", message: "boom", service: "api", project_id: p.id }])
|
|
38
|
-
const result = diagnose(db, p.id)
|
|
39
|
-
expect(result.summary).toContain("error")
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it("groups error_rate_by_service", () => {
|
|
43
|
-
const db = createTestDb()
|
|
44
|
-
const p = seedProject(db)
|
|
45
|
-
ingestBatch(db, [
|
|
46
|
-
{ level: "error", message: "e1", service: "api", project_id: p.id },
|
|
47
|
-
{ level: "info", message: "i1", service: "api", project_id: p.id },
|
|
48
|
-
{ level: "warn", message: "w1", service: "db", project_id: p.id },
|
|
49
|
-
])
|
|
50
|
-
const result = diagnose(db, p.id)
|
|
51
|
-
const api = result.error_rate_by_service.find(s => s.service === "api")
|
|
52
|
-
expect(api?.errors).toBe(1)
|
|
53
|
-
expect(api?.total).toBe(2)
|
|
54
|
-
})
|
|
55
|
-
})
|
package/src/lib/diagnose.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import { parseTime } from "./parse-time.ts"
|
|
3
|
-
|
|
4
|
-
export interface DiagnosisResult {
|
|
5
|
-
project_id: string
|
|
6
|
-
window: string
|
|
7
|
-
score: "green" | "yellow" | "red"
|
|
8
|
-
error_count: number
|
|
9
|
-
warn_count: number
|
|
10
|
-
has_perf_regression: boolean
|
|
11
|
-
top_errors: { message: string; count: number; service: string | null; last_seen: string }[]
|
|
12
|
-
error_rate_by_service: { service: string | null; errors: number; warns: number; total: number }[]
|
|
13
|
-
failing_pages: { page_id: string; url: string; error_count: number }[]
|
|
14
|
-
perf_regressions: { page_id: string; url: string; score_now: number | null; score_prev: number | null; delta: number | null }[]
|
|
15
|
-
summary: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type DiagnoseInclude = "top_errors" | "error_rate" | "failing_pages" | "perf"
|
|
19
|
-
|
|
20
|
-
export function diagnose(db: Database, projectId: string, since?: string, include?: DiagnoseInclude[]): DiagnosisResult {
|
|
21
|
-
const window = parseTime(since) ?? since ?? new Date(Date.now() - 24 * 3600 * 1000).toISOString()
|
|
22
|
-
const all = !include || include.length === 0
|
|
23
|
-
const want = (k: DiagnoseInclude) => all || include!.includes(k)
|
|
24
|
-
|
|
25
|
-
// Top errors by message
|
|
26
|
-
const top_errors = want("top_errors") ? db.prepare(`
|
|
27
|
-
SELECT message, COUNT(*) as count, service, MAX(timestamp) as last_seen
|
|
28
|
-
FROM logs
|
|
29
|
-
WHERE project_id = $p AND level IN ('error','fatal') AND timestamp >= $since
|
|
30
|
-
GROUP BY message, service
|
|
31
|
-
ORDER BY count DESC
|
|
32
|
-
LIMIT 10
|
|
33
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["top_errors"] : []
|
|
34
|
-
|
|
35
|
-
// Error rate by service
|
|
36
|
-
const error_rate_by_service = want("error_rate") ? db.prepare(`
|
|
37
|
-
SELECT service,
|
|
38
|
-
SUM(CASE WHEN level IN ('error','fatal') THEN 1 ELSE 0 END) as errors,
|
|
39
|
-
SUM(CASE WHEN level = 'warn' THEN 1 ELSE 0 END) as warns,
|
|
40
|
-
COUNT(*) as total
|
|
41
|
-
FROM logs
|
|
42
|
-
WHERE project_id = $p AND timestamp >= $since
|
|
43
|
-
GROUP BY service
|
|
44
|
-
ORDER BY errors DESC
|
|
45
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["error_rate_by_service"] : []
|
|
46
|
-
|
|
47
|
-
// Failing pages (most errors)
|
|
48
|
-
const failing_pages = want("failing_pages") ? db.prepare(`
|
|
49
|
-
SELECT l.page_id, p.url, COUNT(*) as error_count
|
|
50
|
-
FROM logs l
|
|
51
|
-
JOIN pages p ON p.id = l.page_id
|
|
52
|
-
WHERE l.project_id = $p AND l.level IN ('error','fatal') AND l.timestamp >= $since AND l.page_id IS NOT NULL
|
|
53
|
-
GROUP BY l.page_id, p.url
|
|
54
|
-
ORDER BY error_count DESC
|
|
55
|
-
LIMIT 10
|
|
56
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["failing_pages"] : []
|
|
57
|
-
|
|
58
|
-
// Perf regressions: compare latest vs previous snapshot per page
|
|
59
|
-
const perf_regressions = want("perf") ? db.prepare(`
|
|
60
|
-
SELECT * FROM (
|
|
61
|
-
SELECT
|
|
62
|
-
cur.page_id,
|
|
63
|
-
p.url,
|
|
64
|
-
cur.score as score_now,
|
|
65
|
-
prev.score as score_prev,
|
|
66
|
-
(cur.score - prev.score) as delta
|
|
67
|
-
FROM performance_snapshots cur
|
|
68
|
-
JOIN pages p ON p.id = cur.page_id
|
|
69
|
-
LEFT JOIN performance_snapshots prev ON prev.page_id = cur.page_id AND prev.id != cur.id
|
|
70
|
-
WHERE cur.project_id = $p
|
|
71
|
-
AND cur.timestamp = (SELECT MAX(timestamp) FROM performance_snapshots WHERE page_id = cur.page_id)
|
|
72
|
-
AND (prev.timestamp = (SELECT MAX(timestamp) FROM performance_snapshots WHERE page_id = cur.page_id AND id != cur.id) OR prev.id IS NULL)
|
|
73
|
-
) WHERE delta < -5 OR delta IS NULL
|
|
74
|
-
ORDER BY delta ASC
|
|
75
|
-
LIMIT 10
|
|
76
|
-
`).all({ $p: projectId }) as DiagnosisResult["perf_regressions"] : []
|
|
77
|
-
|
|
78
|
-
const totalErrors = top_errors.reduce((s, e) => s + e.count, 0)
|
|
79
|
-
const totalWarns = error_rate_by_service.reduce((s, r) => s + r.warns, 0)
|
|
80
|
-
const topService = error_rate_by_service[0]
|
|
81
|
-
const score: "green" | "yellow" | "red" = totalErrors === 0 ? "green" : totalErrors <= 10 ? "yellow" : "red"
|
|
82
|
-
const summary = totalErrors === 0
|
|
83
|
-
? "No errors in this window. All looks good."
|
|
84
|
-
: `${totalErrors} error(s) detected. Worst service: ${topService?.service ?? "unknown"} (${topService?.errors ?? 0} errors). ${failing_pages.length} page(s) with errors. ${perf_regressions.length} perf regression(s).`
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
project_id: projectId, window, score, error_count: totalErrors, warn_count: totalWarns,
|
|
88
|
-
has_perf_regression: perf_regressions.length > 0,
|
|
89
|
-
top_errors, error_rate_by_service, failing_pages, perf_regressions, summary,
|
|
90
|
-
}
|
|
91
|
-
}
|
package/src/lib/export.test.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch } from "./ingest.ts"
|
|
4
|
-
import { exportToCsv, exportToJson } from "./export.ts"
|
|
5
|
-
|
|
6
|
-
function seed(db: ReturnType<typeof createTestDb>) {
|
|
7
|
-
ingestBatch(db, [
|
|
8
|
-
{ level: "error", message: "boom", service: "api" },
|
|
9
|
-
{ level: "info", message: "ok", service: "web" },
|
|
10
|
-
{ level: "warn", message: 'has "quotes"', service: "db" },
|
|
11
|
-
])
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe("exportToJson", () => {
|
|
15
|
-
it("exports all logs as JSON array", () => {
|
|
16
|
-
const db = createTestDb()
|
|
17
|
-
seed(db)
|
|
18
|
-
const chunks: string[] = []
|
|
19
|
-
const count = exportToJson(db, {}, s => chunks.push(s))
|
|
20
|
-
expect(count).toBe(3)
|
|
21
|
-
const parsed = JSON.parse(chunks.join(""))
|
|
22
|
-
expect(Array.isArray(parsed)).toBe(true)
|
|
23
|
-
expect(parsed).toHaveLength(3)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it("filters by level", () => {
|
|
27
|
-
const db = createTestDb()
|
|
28
|
-
seed(db)
|
|
29
|
-
const chunks: string[] = []
|
|
30
|
-
const count = exportToJson(db, { level: "error" }, s => chunks.push(s))
|
|
31
|
-
expect(count).toBe(1)
|
|
32
|
-
const parsed = JSON.parse(chunks.join(""))
|
|
33
|
-
expect(parsed[0].level).toBe("error")
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
describe("exportToCsv", () => {
|
|
38
|
-
it("exports CSV with header", () => {
|
|
39
|
-
const db = createTestDb()
|
|
40
|
-
seed(db)
|
|
41
|
-
const chunks: string[] = []
|
|
42
|
-
const count = exportToCsv(db, {}, s => chunks.push(s))
|
|
43
|
-
expect(count).toBe(3)
|
|
44
|
-
const csv = chunks.join("")
|
|
45
|
-
expect(csv).toContain("id,timestamp,level")
|
|
46
|
-
expect(csv).toContain("error")
|
|
47
|
-
expect(csv).toContain("boom")
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it("escapes CSV quotes", () => {
|
|
51
|
-
const db = createTestDb()
|
|
52
|
-
seed(db)
|
|
53
|
-
const chunks: string[] = []
|
|
54
|
-
exportToCsv(db, { level: "warn" }, s => chunks.push(s))
|
|
55
|
-
const csv = chunks.join("")
|
|
56
|
-
expect(csv).toContain('"has ""quotes"""')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it("filters by service", () => {
|
|
60
|
-
const db = createTestDb()
|
|
61
|
-
seed(db)
|
|
62
|
-
const chunks: string[] = []
|
|
63
|
-
const count = exportToCsv(db, { service: "api" }, s => chunks.push(s))
|
|
64
|
-
expect(count).toBe(1)
|
|
65
|
-
})
|
|
66
|
-
})
|
package/src/lib/export.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { LogRow } from "../types/index.ts"
|
|
3
|
-
|
|
4
|
-
export interface ExportOptions {
|
|
5
|
-
project_id?: string
|
|
6
|
-
since?: string
|
|
7
|
-
until?: string
|
|
8
|
-
level?: string
|
|
9
|
-
service?: string
|
|
10
|
-
limit?: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function* iterLogs(db: Database, opts: ExportOptions): Generator<LogRow> {
|
|
14
|
-
const conditions: string[] = []
|
|
15
|
-
const params: Record<string, unknown> = {}
|
|
16
|
-
if (opts.project_id) { conditions.push("project_id = $p"); params.$p = opts.project_id }
|
|
17
|
-
if (opts.since) { conditions.push("timestamp >= $since"); params.$since = opts.since }
|
|
18
|
-
if (opts.until) { conditions.push("timestamp <= $until"); params.$until = opts.until }
|
|
19
|
-
if (opts.level) { conditions.push("level = $level"); params.$level = opts.level }
|
|
20
|
-
if (opts.service) { conditions.push("service = $service"); params.$service = opts.service }
|
|
21
|
-
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
22
|
-
const limit = opts.limit ?? 100_000
|
|
23
|
-
|
|
24
|
-
// Batch in pages of 1000 to avoid memory issues
|
|
25
|
-
let offset = 0
|
|
26
|
-
while (offset < limit) {
|
|
27
|
-
const batch = db.prepare(`SELECT * FROM logs ${where} ORDER BY timestamp ASC LIMIT 1000 OFFSET $offset`)
|
|
28
|
-
.all({ ...params, $offset: offset }) as LogRow[]
|
|
29
|
-
if (!batch.length) break
|
|
30
|
-
yield* batch
|
|
31
|
-
offset += batch.length
|
|
32
|
-
if (batch.length < 1000) break
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function exportToJson(db: Database, opts: ExportOptions, writeLine: (s: string) => void): number {
|
|
37
|
-
writeLine("[")
|
|
38
|
-
let count = 0
|
|
39
|
-
for (const row of iterLogs(db, opts)) {
|
|
40
|
-
writeLine((count > 0 ? "," : "") + JSON.stringify(row))
|
|
41
|
-
count++
|
|
42
|
-
}
|
|
43
|
-
writeLine("]")
|
|
44
|
-
return count
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const CSV_HEADER = "id,timestamp,level,service,message,trace_id,url\n"
|
|
48
|
-
|
|
49
|
-
export function exportToCsv(db: Database, opts: ExportOptions, writeLine: (s: string) => void): number {
|
|
50
|
-
writeLine(CSV_HEADER)
|
|
51
|
-
let count = 0
|
|
52
|
-
for (const row of iterLogs(db, opts)) {
|
|
53
|
-
const fields = [row.id, row.timestamp, row.level, row.service ?? "", escapeCSV(row.message), row.trace_id ?? "", row.url ?? ""]
|
|
54
|
-
writeLine(fields.join(",") + "\n")
|
|
55
|
-
count++
|
|
56
|
-
}
|
|
57
|
-
return count
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function escapeCSV(s: string): string {
|
|
61
|
-
if (s.includes(",") || s.includes('"') || s.includes("\n")) {
|
|
62
|
-
return `"${s.replace(/"/g, '""')}"`
|
|
63
|
-
}
|
|
64
|
-
return s
|
|
65
|
-
}
|
package/src/lib/github.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { Project } from "../types/index.ts"
|
|
3
|
-
import { updateProject } from "./projects.ts"
|
|
4
|
-
|
|
5
|
-
interface GithubRepo {
|
|
6
|
-
description: string | null
|
|
7
|
-
default_branch: string
|
|
8
|
-
topics: string[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface GithubCommit {
|
|
12
|
-
sha: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function syncGithubRepo(db: Database, project: Project): Promise<Project | null> {
|
|
16
|
-
if (!project.github_repo) return project
|
|
17
|
-
const repo = project.github_repo.replace(/^https?:\/\/github\.com\//, "")
|
|
18
|
-
const headers: Record<string, string> = { "Accept": "application/vnd.github.v3+json" }
|
|
19
|
-
if (process.env.GITHUB_TOKEN) headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const [repoRes, commitRes] = await Promise.all([
|
|
23
|
-
fetch(`https://api.github.com/repos/${repo}`, { headers }),
|
|
24
|
-
fetch(`https://api.github.com/repos/${repo}/commits?per_page=1`, { headers }),
|
|
25
|
-
])
|
|
26
|
-
if (!repoRes.ok) return project
|
|
27
|
-
const repoData = await repoRes.json() as GithubRepo
|
|
28
|
-
const commits = commitRes.ok ? await commitRes.json() as GithubCommit[] : []
|
|
29
|
-
return updateProject(db, project.id, {
|
|
30
|
-
github_description: repoData.description,
|
|
31
|
-
github_branch: repoData.default_branch,
|
|
32
|
-
github_sha: commits[0]?.sha ?? null,
|
|
33
|
-
last_synced_at: new Date().toISOString(),
|
|
34
|
-
})
|
|
35
|
-
} catch {
|
|
36
|
-
return project
|
|
37
|
-
}
|
|
38
|
-
}
|
package/src/lib/health.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch } from "./ingest.ts"
|
|
4
|
-
import { getHealth } from "./health.ts"
|
|
5
|
-
|
|
6
|
-
describe("getHealth", () => {
|
|
7
|
-
it("returns status ok", () => {
|
|
8
|
-
const db = createTestDb()
|
|
9
|
-
const h = getHealth(db)
|
|
10
|
-
expect(h.status).toBe("ok")
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it("counts total logs", () => {
|
|
14
|
-
const db = createTestDb()
|
|
15
|
-
ingestBatch(db, [{ level: "info", message: "a" }, { level: "error", message: "b" }])
|
|
16
|
-
const h = getHealth(db)
|
|
17
|
-
expect(h.total_logs).toBe(2)
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it("returns logs_by_level breakdown", () => {
|
|
21
|
-
const db = createTestDb()
|
|
22
|
-
ingestBatch(db, [{ level: "info", message: "a" }, { level: "error", message: "b" }, { level: "error", message: "c" }])
|
|
23
|
-
const h = getHealth(db)
|
|
24
|
-
expect(h.logs_by_level["error"]).toBe(2)
|
|
25
|
-
expect(h.logs_by_level["info"]).toBe(1)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it("counts projects", () => {
|
|
29
|
-
const db = createTestDb()
|
|
30
|
-
db.prepare("INSERT INTO projects (name) VALUES ('p1')").run()
|
|
31
|
-
db.prepare("INSERT INTO projects (name) VALUES ('p2')").run()
|
|
32
|
-
const h = getHealth(db)
|
|
33
|
-
expect(h.projects).toBe(2)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it("returns uptime_seconds >= 0", () => {
|
|
37
|
-
const h = getHealth(createTestDb())
|
|
38
|
-
expect(h.uptime_seconds).toBeGreaterThanOrEqual(0)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it("returns newest and oldest log timestamps", () => {
|
|
42
|
-
const db = createTestDb()
|
|
43
|
-
ingestBatch(db, [{ level: "info", message: "first" }, { level: "warn", message: "last" }])
|
|
44
|
-
const h = getHealth(db)
|
|
45
|
-
expect(h.oldest_log).toBeTruthy()
|
|
46
|
-
expect(h.newest_log).toBeTruthy()
|
|
47
|
-
})
|
|
48
|
-
})
|
package/src/lib/health.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
|
|
3
|
-
const startTime = Date.now()
|
|
4
|
-
|
|
5
|
-
export interface HealthResult {
|
|
6
|
-
status: "ok"
|
|
7
|
-
uptime_seconds: number
|
|
8
|
-
db_size_bytes: number | null
|
|
9
|
-
projects: number
|
|
10
|
-
total_logs: number
|
|
11
|
-
logs_by_level: Record<string, number>
|
|
12
|
-
oldest_log: string | null
|
|
13
|
-
newest_log: string | null
|
|
14
|
-
scheduler_jobs: number
|
|
15
|
-
open_issues: number
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function getHealth(db: Database): HealthResult {
|
|
19
|
-
const projects = (db.prepare("SELECT COUNT(*) as c FROM projects").get() as { c: number }).c
|
|
20
|
-
const total_logs = (db.prepare("SELECT COUNT(*) as c FROM logs").get() as { c: number }).c
|
|
21
|
-
const scheduler_jobs = (db.prepare("SELECT COUNT(*) as c FROM scan_jobs WHERE enabled = 1").get() as { c: number }).c
|
|
22
|
-
const open_issues = (db.prepare("SELECT COUNT(*) as c FROM issues WHERE status = 'open'").get() as { c: number }).c
|
|
23
|
-
|
|
24
|
-
const levelRows = db.prepare("SELECT level, COUNT(*) as c FROM logs GROUP BY level").all() as { level: string; c: number }[]
|
|
25
|
-
const logs_by_level = Object.fromEntries(levelRows.map(r => [r.level, r.c]))
|
|
26
|
-
|
|
27
|
-
const oldest = db.prepare("SELECT MIN(timestamp) as t FROM logs").get() as { t: string | null }
|
|
28
|
-
const newest = db.prepare("SELECT MAX(timestamp) as t FROM logs").get() as { t: string | null }
|
|
29
|
-
|
|
30
|
-
let db_size_bytes: number | null = null
|
|
31
|
-
try {
|
|
32
|
-
const dbPath = process.env.HASNA_LOGS_DB_PATH ?? process.env.LOGS_DB_PATH
|
|
33
|
-
if (dbPath) {
|
|
34
|
-
const { statSync } = require("node:fs")
|
|
35
|
-
db_size_bytes = statSync(dbPath).size
|
|
36
|
-
}
|
|
37
|
-
} catch { /* in-memory or not accessible */ }
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
status: "ok",
|
|
41
|
-
uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
|
|
42
|
-
db_size_bytes,
|
|
43
|
-
projects,
|
|
44
|
-
total_logs,
|
|
45
|
-
logs_by_level,
|
|
46
|
-
oldest_log: oldest.t,
|
|
47
|
-
newest_log: newest.t,
|
|
48
|
-
scheduler_jobs,
|
|
49
|
-
open_issues,
|
|
50
|
-
}
|
|
51
|
-
}
|