@hasna/logs 0.2.0 → 0.3.1
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/bun.lock +336 -0
- package/dist/cli/index.js +8 -8
- package/dist/export-yjaar93b.js +10 -0
- package/dist/health-egdb00st.js +8 -0
- package/dist/index-3dr7d80h.js +57 -0
- package/dist/index-5tvnhvgr.js +536 -0
- package/dist/index-6y8pmes4.js +45 -0
- package/dist/index-eh9bkbpa.js +70 -0
- package/dist/index-g8dczzvv.js +30 -0
- package/dist/index-rbrsvsyh.js +88 -0
- package/dist/index-wbsq8qjd.js +1241 -0
- package/dist/index-yb8yd4j6.js +39 -0
- package/dist/jobs-02z4fzsn.js +22 -0
- package/dist/mcp/index.js +167 -74
- package/dist/query-tcg3bm9s.js +14 -0
- package/dist/server/index.js +33 -8
- package/package.json +1 -1
- package/src/lib/count.test.ts +44 -0
- package/src/lib/count.ts +45 -0
- package/src/lib/diagnose.ts +26 -11
- package/src/lib/parse-time.test.ts +37 -0
- package/src/lib/parse-time.ts +14 -0
- package/src/lib/projects.ts +10 -0
- package/src/lib/query.ts +10 -2
- package/src/lib/summarize.ts +2 -1
- package/src/mcp/index.ts +137 -68
- package/src/server/routes/logs.ts +28 -1
package/src/lib/count.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
11
|
+
|
|
12
|
+
export function countLogs(db: Database, opts: {
|
|
13
|
+
project_id?: string
|
|
14
|
+
service?: string
|
|
15
|
+
level?: string
|
|
16
|
+
since?: string
|
|
17
|
+
until?: string
|
|
18
|
+
}): LogCount {
|
|
19
|
+
const conditions: string[] = []
|
|
20
|
+
const params: Record<string, unknown> = {}
|
|
21
|
+
|
|
22
|
+
if (opts.project_id) { conditions.push("project_id = $p"); params.$p = opts.project_id }
|
|
23
|
+
if (opts.service) { conditions.push("service = $service"); params.$service = opts.service }
|
|
24
|
+
if (opts.level) { conditions.push("level = $level"); params.$level = opts.level }
|
|
25
|
+
const since = parseTime(opts.since)
|
|
26
|
+
const until = parseTime(opts.until)
|
|
27
|
+
if (since) { conditions.push("timestamp >= $since"); params.$since = since }
|
|
28
|
+
if (until) { conditions.push("timestamp <= $until"); params.$until = until }
|
|
29
|
+
|
|
30
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
31
|
+
|
|
32
|
+
const byLevel = db.prepare(`SELECT level, COUNT(*) as c FROM logs ${where} GROUP BY level`)
|
|
33
|
+
.all(params) as { level: string; c: number }[]
|
|
34
|
+
|
|
35
|
+
const by_level = Object.fromEntries(byLevel.map(r => [r.level, r.c]))
|
|
36
|
+
const total = byLevel.reduce((s, r) => s + r.c, 0)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
total,
|
|
40
|
+
errors: by_level["error"] ?? 0,
|
|
41
|
+
warns: by_level["warn"] ?? 0,
|
|
42
|
+
fatals: by_level["fatal"] ?? 0,
|
|
43
|
+
by_level,
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/lib/diagnose.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { parseTime } from "./parse-time.ts"
|
|
2
3
|
|
|
3
4
|
export interface DiagnosisResult {
|
|
4
5
|
project_id: string
|
|
5
6
|
window: string
|
|
7
|
+
score: "green" | "yellow" | "red"
|
|
8
|
+
error_count: number
|
|
9
|
+
warn_count: number
|
|
10
|
+
has_perf_regression: boolean
|
|
6
11
|
top_errors: { message: string; count: number; service: string | null; last_seen: string }[]
|
|
7
12
|
error_rate_by_service: { service: string | null; errors: number; warns: number; total: number }[]
|
|
8
13
|
failing_pages: { page_id: string; url: string; error_count: number }[]
|
|
@@ -10,21 +15,25 @@ export interface DiagnosisResult {
|
|
|
10
15
|
summary: string
|
|
11
16
|
}
|
|
12
17
|
|
|
13
|
-
export
|
|
14
|
-
|
|
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)
|
|
15
24
|
|
|
16
25
|
// Top errors by message
|
|
17
|
-
const top_errors = db.prepare(`
|
|
26
|
+
const top_errors = want("top_errors") ? db.prepare(`
|
|
18
27
|
SELECT message, COUNT(*) as count, service, MAX(timestamp) as last_seen
|
|
19
28
|
FROM logs
|
|
20
29
|
WHERE project_id = $p AND level IN ('error','fatal') AND timestamp >= $since
|
|
21
30
|
GROUP BY message, service
|
|
22
31
|
ORDER BY count DESC
|
|
23
32
|
LIMIT 10
|
|
24
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["top_errors"]
|
|
33
|
+
`).all({ $p: projectId, $since: window }) as DiagnosisResult["top_errors"] : []
|
|
25
34
|
|
|
26
35
|
// Error rate by service
|
|
27
|
-
const error_rate_by_service = db.prepare(`
|
|
36
|
+
const error_rate_by_service = want("error_rate") ? db.prepare(`
|
|
28
37
|
SELECT service,
|
|
29
38
|
SUM(CASE WHEN level IN ('error','fatal') THEN 1 ELSE 0 END) as errors,
|
|
30
39
|
SUM(CASE WHEN level = 'warn' THEN 1 ELSE 0 END) as warns,
|
|
@@ -33,10 +42,10 @@ export function diagnose(db: Database, projectId: string, since?: string): Diagn
|
|
|
33
42
|
WHERE project_id = $p AND timestamp >= $since
|
|
34
43
|
GROUP BY service
|
|
35
44
|
ORDER BY errors DESC
|
|
36
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["error_rate_by_service"]
|
|
45
|
+
`).all({ $p: projectId, $since: window }) as DiagnosisResult["error_rate_by_service"] : []
|
|
37
46
|
|
|
38
47
|
// Failing pages (most errors)
|
|
39
|
-
const failing_pages = db.prepare(`
|
|
48
|
+
const failing_pages = want("failing_pages") ? db.prepare(`
|
|
40
49
|
SELECT l.page_id, p.url, COUNT(*) as error_count
|
|
41
50
|
FROM logs l
|
|
42
51
|
JOIN pages p ON p.id = l.page_id
|
|
@@ -44,10 +53,10 @@ export function diagnose(db: Database, projectId: string, since?: string): Diagn
|
|
|
44
53
|
GROUP BY l.page_id, p.url
|
|
45
54
|
ORDER BY error_count DESC
|
|
46
55
|
LIMIT 10
|
|
47
|
-
`).all({ $p: projectId, $since: window }) as DiagnosisResult["failing_pages"]
|
|
56
|
+
`).all({ $p: projectId, $since: window }) as DiagnosisResult["failing_pages"] : []
|
|
48
57
|
|
|
49
58
|
// Perf regressions: compare latest vs previous snapshot per page
|
|
50
|
-
const perf_regressions = db.prepare(`
|
|
59
|
+
const perf_regressions = want("perf") ? db.prepare(`
|
|
51
60
|
SELECT * FROM (
|
|
52
61
|
SELECT
|
|
53
62
|
cur.page_id,
|
|
@@ -64,13 +73,19 @@ export function diagnose(db: Database, projectId: string, since?: string): Diagn
|
|
|
64
73
|
) WHERE delta < -5 OR delta IS NULL
|
|
65
74
|
ORDER BY delta ASC
|
|
66
75
|
LIMIT 10
|
|
67
|
-
`).all({ $p: projectId }) as DiagnosisResult["perf_regressions"]
|
|
76
|
+
`).all({ $p: projectId }) as DiagnosisResult["perf_regressions"] : []
|
|
68
77
|
|
|
69
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)
|
|
70
80
|
const topService = error_rate_by_service[0]
|
|
81
|
+
const score: "green" | "yellow" | "red" = totalErrors === 0 ? "green" : totalErrors <= 10 ? "yellow" : "red"
|
|
71
82
|
const summary = totalErrors === 0
|
|
72
83
|
? "No errors in this window. All looks good."
|
|
73
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).`
|
|
74
85
|
|
|
75
|
-
return {
|
|
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
|
+
}
|
|
76
91
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseTime } from "./parse-time.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseTime", () => {
|
|
5
|
+
it("returns undefined for undefined input", () => expect(parseTime(undefined)).toBeUndefined())
|
|
6
|
+
it("returns ISO string unchanged", () => {
|
|
7
|
+
const iso = "2026-01-01T00:00:00.000Z"
|
|
8
|
+
expect(parseTime(iso)).toBe(iso)
|
|
9
|
+
})
|
|
10
|
+
it("parses 30m", () => {
|
|
11
|
+
const result = parseTime("30m")!
|
|
12
|
+
const diff = Date.now() - new Date(result).getTime()
|
|
13
|
+
expect(diff).toBeGreaterThan(29 * 60 * 1000)
|
|
14
|
+
expect(diff).toBeLessThan(31 * 60 * 1000)
|
|
15
|
+
})
|
|
16
|
+
it("parses 1h", () => {
|
|
17
|
+
const result = parseTime("1h")!
|
|
18
|
+
const diff = Date.now() - new Date(result).getTime()
|
|
19
|
+
expect(diff).toBeGreaterThan(59 * 60 * 1000)
|
|
20
|
+
expect(diff).toBeLessThan(61 * 60 * 1000)
|
|
21
|
+
})
|
|
22
|
+
it("parses 7d", () => {
|
|
23
|
+
const result = parseTime("7d")!
|
|
24
|
+
const diff = Date.now() - new Date(result).getTime()
|
|
25
|
+
expect(diff).toBeGreaterThan(6.9 * 86400 * 1000)
|
|
26
|
+
expect(diff).toBeLessThan(7.1 * 86400 * 1000)
|
|
27
|
+
})
|
|
28
|
+
it("parses 1w", () => {
|
|
29
|
+
const result = parseTime("1w")!
|
|
30
|
+
const diff = Date.now() - new Date(result).getTime()
|
|
31
|
+
expect(diff).toBeGreaterThan(6.9 * 86400 * 1000)
|
|
32
|
+
})
|
|
33
|
+
it("returns unknown strings unchanged", () => {
|
|
34
|
+
expect(parseTime("yesterday")).toBe("yesterday")
|
|
35
|
+
expect(parseTime("now")).toBe("now")
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a relative time string or ISO timestamp into an ISO timestamp.
|
|
3
|
+
* Accepts: "30m", "1h", "2h", "24h", "7d", "1w" or any ISO string.
|
|
4
|
+
* Returns the input unchanged if it doesn't match a relative format.
|
|
5
|
+
*/
|
|
6
|
+
export function parseTime(val: string | undefined): string | undefined {
|
|
7
|
+
if (!val) return undefined
|
|
8
|
+
const m = val.match(/^(\d+(?:\.\d+)?)(m|h|d|w)$/)
|
|
9
|
+
if (!m) return val
|
|
10
|
+
const n = parseFloat(m[1]!)
|
|
11
|
+
const unit = m[2]!
|
|
12
|
+
const ms = n * ({ m: 60, h: 3600, d: 86400, w: 604800 }[unit]!) * 1000
|
|
13
|
+
return new Date(Date.now() - ms).toISOString()
|
|
14
|
+
}
|
package/src/lib/projects.ts
CHANGED
|
@@ -54,6 +54,16 @@ export function getPage(db: Database, id: string): Page | null {
|
|
|
54
54
|
return db.prepare("SELECT * FROM pages WHERE id = $id").get({ $id: id }) as Page | null
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Resolves a project ID or name to a project ID. Returns null if not found or input is empty. */
|
|
58
|
+
export function resolveProjectId(db: Database, idOrName: string | undefined | null): string | null {
|
|
59
|
+
if (!idOrName) return null
|
|
60
|
+
// Looks like a hex ID (8+ hex chars)
|
|
61
|
+
if (/^[0-9a-f]{8,}$/i.test(idOrName)) return idOrName
|
|
62
|
+
// Try name lookup (case-insensitive)
|
|
63
|
+
const p = db.prepare("SELECT id FROM projects WHERE LOWER(name) = LOWER($n)").get({ $n: idOrName }) as { id: string } | null
|
|
64
|
+
return p?.id ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export function touchPage(db: Database, id: string): void {
|
|
58
68
|
db.run("UPDATE pages SET last_scanned_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = $id", { $id: id })
|
|
59
69
|
}
|
package/src/lib/query.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import type { LogQuery, LogRow } from "../types/index.ts"
|
|
3
|
+
import { parseTime } from "./parse-time.ts"
|
|
3
4
|
|
|
4
5
|
export function searchLogs(db: Database, q: LogQuery): LogRow[] {
|
|
5
6
|
const conditions: string[] = []
|
|
@@ -9,8 +10,8 @@ export function searchLogs(db: Database, q: LogQuery): LogRow[] {
|
|
|
9
10
|
if (q.page_id) { conditions.push("l.page_id = $page_id"); params.$page_id = q.page_id }
|
|
10
11
|
if (q.service) { conditions.push("l.service = $service"); params.$service = q.service }
|
|
11
12
|
if (q.trace_id) { conditions.push("l.trace_id = $trace_id"); params.$trace_id = q.trace_id }
|
|
12
|
-
if (q.since) { conditions.push("l.timestamp >= $since"); params.$since = q.since }
|
|
13
|
-
if (q.until) { conditions.push("l.timestamp <= $until"); params.$until = q.until }
|
|
13
|
+
if (q.since) { conditions.push("l.timestamp >= $since"); params.$since = parseTime(q.since) ?? q.since }
|
|
14
|
+
if (q.until) { conditions.push("l.timestamp <= $until"); params.$until = parseTime(q.until) ?? q.until }
|
|
14
15
|
|
|
15
16
|
if (q.level) {
|
|
16
17
|
const levels = Array.isArray(q.level) ? q.level : [q.level]
|
|
@@ -54,3 +55,10 @@ export function getLogContext(db: Database, traceId: string): LogRow[] {
|
|
|
54
55
|
return db.prepare("SELECT * FROM logs WHERE trace_id = $t ORDER BY timestamp ASC")
|
|
55
56
|
.all({ $t: traceId }) as LogRow[]
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
export function getLogContextFromId(db: Database, logId: string): LogRow[] {
|
|
60
|
+
const log = db.prepare("SELECT * FROM logs WHERE id = $id").get({ $id: logId }) as LogRow | null
|
|
61
|
+
if (!log) return []
|
|
62
|
+
if (log.trace_id) return getLogContext(db, log.trace_id)
|
|
63
|
+
return [log]
|
|
64
|
+
}
|
package/src/lib/summarize.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import type { LogSummary } from "../types/index.ts"
|
|
3
|
+
import { parseTime } from "./parse-time.ts"
|
|
3
4
|
|
|
4
5
|
export function summarizeLogs(db: Database, projectId?: string, since?: string): LogSummary[] {
|
|
5
6
|
const conditions: string[] = ["level IN ('warn','error','fatal')"]
|
|
6
7
|
const params: Record<string, unknown> = {}
|
|
7
8
|
|
|
8
9
|
if (projectId) { conditions.push("project_id = $project_id"); params.$project_id = projectId }
|
|
9
|
-
if (since) { conditions.push("timestamp >= $since"); params.$since = since }
|
|
10
|
+
if (since) { conditions.push("timestamp >= $since"); params.$since = parseTime(since) ?? since }
|
|
10
11
|
|
|
11
12
|
const where = `WHERE ${conditions.join(" AND ")}`
|
|
12
13
|
const sql = `
|
package/src/mcp/index.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
4
4
|
import { z } from "zod"
|
|
5
5
|
import { getDb } from "../db/index.ts"
|
|
6
|
-
import { ingestLog } from "../lib/ingest.ts"
|
|
7
|
-
import { getLogContext, searchLogs, tailLogs } from "../lib/query.ts"
|
|
6
|
+
import { ingestBatch, ingestLog } from "../lib/ingest.ts"
|
|
7
|
+
import { getLogContext, getLogContextFromId, searchLogs, tailLogs } from "../lib/query.ts"
|
|
8
8
|
import { summarizeLogs } from "../lib/summarize.ts"
|
|
9
|
+
import { countLogs } from "../lib/count.ts"
|
|
9
10
|
import { createJob, listJobs } from "../lib/jobs.ts"
|
|
10
|
-
import { createPage, createProject, listPages, listProjects } from "../lib/projects.ts"
|
|
11
|
+
import { createPage, createProject, listPages, listProjects, resolveProjectId } from "../lib/projects.ts"
|
|
11
12
|
import { getLatestSnapshot, getPerfTrend, scoreLabel } from "../lib/perf.ts"
|
|
12
13
|
import { createAlertRule, deleteAlertRule, listAlertRules } from "../lib/alerts.ts"
|
|
13
14
|
import { listIssues, updateIssueStatus } from "../lib/issues.ts"
|
|
@@ -15,82 +16,117 @@ import { diagnose } from "../lib/diagnose.ts"
|
|
|
15
16
|
import { compare } from "../lib/compare.ts"
|
|
16
17
|
import { getHealth } from "../lib/health.ts"
|
|
17
18
|
import { getSessionContext } from "../lib/session-context.ts"
|
|
19
|
+
import { parseTime } from "../lib/parse-time.ts"
|
|
18
20
|
import type { LogLevel, LogRow } from "../types/index.ts"
|
|
19
21
|
|
|
20
22
|
const db = getDb()
|
|
21
|
-
const server = new McpServer({ name: "logs", version: "0.
|
|
23
|
+
const server = new McpServer({ name: "logs", version: "0.3.0" })
|
|
22
24
|
|
|
23
25
|
const BRIEF_FIELDS: (keyof LogRow)[] = ["id", "timestamp", "level", "message", "service"]
|
|
24
26
|
|
|
25
27
|
function applyBrief(rows: LogRow[], brief = true): unknown[] {
|
|
26
28
|
if (!brief) return rows
|
|
27
|
-
return rows.map(r => ({
|
|
29
|
+
return rows.map(r => ({
|
|
30
|
+
id: r.id,
|
|
31
|
+
timestamp: r.timestamp,
|
|
32
|
+
level: r.level,
|
|
33
|
+
message: r.message,
|
|
34
|
+
service: r.service,
|
|
35
|
+
age_seconds: Math.floor((Date.now() - new Date(r.timestamp).getTime()) / 1000),
|
|
36
|
+
}))
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
function rp(idOrName?: string): string | undefined {
|
|
40
|
+
if (!idOrName) return undefined
|
|
41
|
+
return resolveProjectId(db, idOrName) ?? idOrName
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Tool registry with param signatures for discoverability
|
|
45
|
+
const TOOLS: Record<string, { desc: string; params: string }> = {
|
|
46
|
+
register_project: { desc: "Register a project", params: "(name, github_repo?, base_url?, description?)" },
|
|
47
|
+
register_page: { desc: "Register a page URL to a project", params: "(project_id, url, path?, name?)" },
|
|
48
|
+
create_scan_job: { desc: "Schedule headless page scans", params: "(project_id, schedule, page_id?)" },
|
|
49
|
+
resolve_project: { desc: "Resolve project name to ID", params: "(name)" },
|
|
50
|
+
log_push: { desc: "Push a single log entry", params: "(level, message, project_id?, service?, trace_id?, metadata?)" },
|
|
51
|
+
log_push_batch: { desc: "Push multiple log entries in one call", params: "(entries: Array<{level, message, project_id?, service?, trace_id?}>)" },
|
|
52
|
+
log_search: { desc: "Search logs", params: "(project_id?, level?, since?, until?, text?, service?, limit?=100, brief?=true)" },
|
|
53
|
+
log_tail: { desc: "Get N most recent logs", params: "(project_id?, n?=50, brief?=true)" },
|
|
54
|
+
log_count: { desc: "Count logs — zero token cost, pure signal", params: "(project_id?, service?, level?, since?, until?)" },
|
|
55
|
+
log_recent_errors: { desc: "Shortcut: recent errors + fatals", params: "(project_id?, since?='1h', limit?=20)" },
|
|
56
|
+
log_summary: { desc: "Error/warn counts by service", params: "(project_id?, since?)" },
|
|
57
|
+
log_context: { desc: "All logs for a trace_id", params: "(trace_id, brief?=true)" },
|
|
58
|
+
log_context_from_id: { desc: "Trace context from a log ID (no trace_id needed)", params: "(log_id, brief?=true)" },
|
|
59
|
+
log_diagnose: { desc: "Full diagnosis: score, top errors, failing pages, perf regressions", params: "(project_id, since?='24h', include?=['top_errors','error_rate','failing_pages','perf'])" },
|
|
60
|
+
log_compare: { desc: "Diff two time windows for new/resolved errors", params: "(project_id, a_since, a_until, b_since, b_until)" },
|
|
61
|
+
log_session_context: { desc: "Logs + session metadata for a session_id", params: "(session_id, brief?=true)" },
|
|
62
|
+
perf_snapshot: { desc: "Latest performance snapshot", params: "(project_id, page_id?)" },
|
|
63
|
+
perf_trend: { desc: "Performance over time", params: "(project_id, page_id?, since?, limit?=50)" },
|
|
64
|
+
scan_status: { desc: "Last scan jobs", params: "(project_id?)" },
|
|
65
|
+
list_projects: { desc: "List all projects", params: "()" },
|
|
66
|
+
list_pages: { desc: "List pages for a project", params: "(project_id)" },
|
|
67
|
+
list_issues: { desc: "List grouped error issues", params: "(project_id?, status?, limit?=50)" },
|
|
68
|
+
resolve_issue: { desc: "Update issue status", params: "(id, status: open|resolved|ignored)" },
|
|
69
|
+
create_alert_rule: { desc: "Create alert rule", params: "(project_id, name, level?, threshold_count?, window_seconds?, webhook_url?)" },
|
|
70
|
+
list_alert_rules: { desc: "List alert rules", params: "(project_id?)" },
|
|
71
|
+
delete_alert_rule: { desc: "Delete alert rule", params: "(id)" },
|
|
72
|
+
get_health: { desc: "Server health + DB stats", params: "()" },
|
|
73
|
+
search_tools: { desc: "Search tools by keyword — returns names, descriptions, param signatures", params: "(query)" },
|
|
74
|
+
describe_tools: { desc: "List all tools with descriptions and param signatures", params: "()" },
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
server.tool("search_tools", { query: z.string() }, ({ query }) => {
|
|
58
78
|
const q = query.toLowerCase()
|
|
59
|
-
const matches = Object.entries(TOOLS).filter(([k, v]) => k.includes(q) || v.toLowerCase().includes(q))
|
|
60
|
-
|
|
79
|
+
const matches = Object.entries(TOOLS).filter(([k, v]) => k.includes(q) || v.desc.toLowerCase().includes(q))
|
|
80
|
+
const text = matches.map(([k, v]) => `${k}${v.params} — ${v.desc}`).join("\n") || "No matches"
|
|
81
|
+
return { content: [{ type: "text", text }] }
|
|
61
82
|
})
|
|
62
83
|
|
|
63
84
|
server.tool("describe_tools", {}, () => ({
|
|
64
|
-
content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}
|
|
85
|
+
content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}${v.params} — ${v.desc}`).join("\n") }]
|
|
65
86
|
}))
|
|
66
87
|
|
|
88
|
+
server.tool("resolve_project", { name: z.string() }, ({ name }) => {
|
|
89
|
+
const id = resolveProjectId(db, name)
|
|
90
|
+
const project = id ? db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: id }) : null
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify(project ?? { error: `Project '${name}' not found` }) }] }
|
|
92
|
+
})
|
|
93
|
+
|
|
67
94
|
server.tool("register_project", {
|
|
68
95
|
name: z.string(), github_repo: z.string().optional(), base_url: z.string().optional(), description: z.string().optional(),
|
|
69
96
|
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createProject(db, args)) }] }))
|
|
70
97
|
|
|
71
98
|
server.tool("register_page", {
|
|
72
99
|
project_id: z.string(), url: z.string(), path: z.string().optional(), name: z.string().optional(),
|
|
73
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, args)) }] }))
|
|
100
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }))
|
|
74
101
|
|
|
75
102
|
server.tool("create_scan_job", {
|
|
76
103
|
project_id: z.string(), schedule: z.string(), page_id: z.string().optional(),
|
|
77
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, args)) }] }))
|
|
104
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }))
|
|
78
105
|
|
|
79
106
|
server.tool("log_push", {
|
|
80
107
|
level: z.enum(["debug", "info", "warn", "error", "fatal"]),
|
|
81
108
|
message: z.string(),
|
|
82
|
-
project_id: z.string().optional(),
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
session_id: z.string().optional(),
|
|
86
|
-
agent: z.string().optional(),
|
|
87
|
-
url: z.string().optional(),
|
|
109
|
+
project_id: z.string().optional(), service: z.string().optional(),
|
|
110
|
+
trace_id: z.string().optional(), session_id: z.string().optional(),
|
|
111
|
+
agent: z.string().optional(), url: z.string().optional(),
|
|
88
112
|
metadata: z.record(z.unknown()).optional(),
|
|
89
113
|
}, (args) => {
|
|
90
|
-
const row = ingestLog(db, args)
|
|
114
|
+
const row = ingestLog(db, { ...args, project_id: rp(args.project_id) })
|
|
91
115
|
return { content: [{ type: "text", text: `Logged: ${row.id}` }] }
|
|
92
116
|
})
|
|
93
117
|
|
|
118
|
+
server.tool("log_push_batch", {
|
|
119
|
+
entries: z.array(z.object({
|
|
120
|
+
level: z.enum(["debug", "info", "warn", "error", "fatal"]),
|
|
121
|
+
message: z.string(),
|
|
122
|
+
project_id: z.string().optional(), service: z.string().optional(),
|
|
123
|
+
trace_id: z.string().optional(), metadata: z.record(z.unknown()).optional(),
|
|
124
|
+
})),
|
|
125
|
+
}, ({ entries }) => {
|
|
126
|
+
const rows = ingestBatch(db, entries.map(e => ({ ...e, project_id: rp(e.project_id) })))
|
|
127
|
+
return { content: [{ type: "text", text: `Logged ${rows.length} entries` }] }
|
|
128
|
+
})
|
|
129
|
+
|
|
94
130
|
server.tool("log_search", {
|
|
95
131
|
project_id: z.string().optional(), page_id: z.string().optional(),
|
|
96
132
|
level: z.string().optional(), service: z.string().optional(),
|
|
@@ -98,34 +134,66 @@ server.tool("log_search", {
|
|
|
98
134
|
text: z.string().optional(), trace_id: z.string().optional(),
|
|
99
135
|
limit: z.number().optional(), brief: z.boolean().optional(),
|
|
100
136
|
}, (args) => {
|
|
101
|
-
const rows = searchLogs(db, {
|
|
137
|
+
const rows = searchLogs(db, {
|
|
138
|
+
...args,
|
|
139
|
+
project_id: rp(args.project_id),
|
|
140
|
+
level: args.level ? (args.level.split(",") as LogLevel[]) : undefined,
|
|
141
|
+
since: parseTime(args.since) ?? args.since,
|
|
142
|
+
until: parseTime(args.until) ?? args.until,
|
|
143
|
+
})
|
|
102
144
|
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, args.brief !== false)) }] }
|
|
103
145
|
})
|
|
104
146
|
|
|
105
147
|
server.tool("log_tail", {
|
|
106
148
|
project_id: z.string().optional(), n: z.number().optional(), brief: z.boolean().optional(),
|
|
107
149
|
}, ({ project_id, n, brief }) => {
|
|
108
|
-
const rows = tailLogs(db, project_id, n ?? 50)
|
|
150
|
+
const rows = tailLogs(db, rp(project_id), n ?? 50)
|
|
109
151
|
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
|
|
110
152
|
})
|
|
111
153
|
|
|
154
|
+
server.tool("log_count", {
|
|
155
|
+
project_id: z.string().optional(), service: z.string().optional(),
|
|
156
|
+
level: z.string().optional(), since: z.string().optional(), until: z.string().optional(),
|
|
157
|
+
}, (args) => ({
|
|
158
|
+
content: [{ type: "text", text: JSON.stringify(countLogs(db, { ...args, project_id: rp(args.project_id) })) }]
|
|
159
|
+
}))
|
|
160
|
+
|
|
161
|
+
server.tool("log_recent_errors", {
|
|
162
|
+
project_id: z.string().optional(), since: z.string().optional(), limit: z.number().optional(),
|
|
163
|
+
}, ({ project_id, since, limit }) => {
|
|
164
|
+
const rows = searchLogs(db, {
|
|
165
|
+
project_id: rp(project_id),
|
|
166
|
+
level: ["error", "fatal"],
|
|
167
|
+
since: parseTime(since ?? "1h"),
|
|
168
|
+
limit: limit ?? 20,
|
|
169
|
+
})
|
|
170
|
+
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, true)) }] }
|
|
171
|
+
})
|
|
172
|
+
|
|
112
173
|
server.tool("log_summary", {
|
|
113
174
|
project_id: z.string().optional(), since: z.string().optional(),
|
|
114
175
|
}, ({ project_id, since }) => ({
|
|
115
|
-
content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, project_id, since)) }]
|
|
176
|
+
content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, rp(project_id), parseTime(since) ?? since)) }]
|
|
116
177
|
}))
|
|
117
178
|
|
|
118
179
|
server.tool("log_context", {
|
|
119
180
|
trace_id: z.string(), brief: z.boolean().optional(),
|
|
120
|
-
}, ({ trace_id, brief }) => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
}, ({ trace_id, brief }) => ({
|
|
182
|
+
content: [{ type: "text", text: JSON.stringify(applyBrief(getLogContext(db, trace_id), brief !== false)) }]
|
|
183
|
+
}))
|
|
184
|
+
|
|
185
|
+
server.tool("log_context_from_id", {
|
|
186
|
+
log_id: z.string(), brief: z.boolean().optional(),
|
|
187
|
+
}, ({ log_id, brief }) => ({
|
|
188
|
+
content: [{ type: "text", text: JSON.stringify(applyBrief(getLogContextFromId(db, log_id), brief !== false)) }]
|
|
189
|
+
}))
|
|
124
190
|
|
|
125
191
|
server.tool("log_diagnose", {
|
|
126
|
-
project_id: z.string(),
|
|
127
|
-
|
|
128
|
-
|
|
192
|
+
project_id: z.string(),
|
|
193
|
+
since: z.string().optional(),
|
|
194
|
+
include: z.array(z.enum(["top_errors", "error_rate", "failing_pages", "perf"])).optional(),
|
|
195
|
+
}, ({ project_id, since, include }) => ({
|
|
196
|
+
content: [{ type: "text", text: JSON.stringify(diagnose(db, rp(project_id) ?? project_id, since, include)) }]
|
|
129
197
|
}))
|
|
130
198
|
|
|
131
199
|
server.tool("log_compare", {
|
|
@@ -133,26 +201,35 @@ server.tool("log_compare", {
|
|
|
133
201
|
a_since: z.string(), a_until: z.string(),
|
|
134
202
|
b_since: z.string(), b_until: z.string(),
|
|
135
203
|
}, ({ project_id, a_since, a_until, b_since, b_until }) => ({
|
|
136
|
-
content: [{ type: "text", text: JSON.stringify(compare(db, project_id
|
|
204
|
+
content: [{ type: "text", text: JSON.stringify(compare(db, rp(project_id) ?? project_id,
|
|
205
|
+
parseTime(a_since) ?? a_since, parseTime(a_until) ?? a_until,
|
|
206
|
+
parseTime(b_since) ?? b_since, parseTime(b_until) ?? b_until)) }]
|
|
137
207
|
}))
|
|
138
208
|
|
|
209
|
+
server.tool("log_session_context", {
|
|
210
|
+
session_id: z.string(), brief: z.boolean().optional(),
|
|
211
|
+
}, async ({ session_id, brief }) => {
|
|
212
|
+
const ctx = await getSessionContext(db, session_id)
|
|
213
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...ctx, logs: applyBrief(ctx.logs, brief !== false) }) }] }
|
|
214
|
+
})
|
|
215
|
+
|
|
139
216
|
server.tool("perf_snapshot", {
|
|
140
217
|
project_id: z.string(), page_id: z.string().optional(),
|
|
141
218
|
}, ({ project_id, page_id }) => {
|
|
142
|
-
const snap = getLatestSnapshot(db, project_id, page_id)
|
|
219
|
+
const snap = getLatestSnapshot(db, rp(project_id) ?? project_id, page_id)
|
|
143
220
|
return { content: [{ type: "text", text: JSON.stringify(snap ? { ...snap, label: scoreLabel(snap.score) } : null) }] }
|
|
144
221
|
})
|
|
145
222
|
|
|
146
223
|
server.tool("perf_trend", {
|
|
147
224
|
project_id: z.string(), page_id: z.string().optional(), since: z.string().optional(), limit: z.number().optional(),
|
|
148
225
|
}, ({ project_id, page_id, since, limit }) => ({
|
|
149
|
-
content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, project_id, page_id, since, limit ?? 50)) }]
|
|
226
|
+
content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, rp(project_id) ?? project_id, page_id, parseTime(since) ?? since, limit ?? 50)) }]
|
|
150
227
|
}))
|
|
151
228
|
|
|
152
229
|
server.tool("scan_status", {
|
|
153
230
|
project_id: z.string().optional(),
|
|
154
231
|
}, ({ project_id }) => ({
|
|
155
|
-
content: [{ type: "text", text: JSON.stringify(listJobs(db, project_id)) }]
|
|
232
|
+
content: [{ type: "text", text: JSON.stringify(listJobs(db, rp(project_id))) }]
|
|
156
233
|
}))
|
|
157
234
|
|
|
158
235
|
server.tool("list_projects", {}, () => ({
|
|
@@ -160,13 +237,13 @@ server.tool("list_projects", {}, () => ({
|
|
|
160
237
|
}))
|
|
161
238
|
|
|
162
239
|
server.tool("list_pages", { project_id: z.string() }, ({ project_id }) => ({
|
|
163
|
-
content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }]
|
|
240
|
+
content: [{ type: "text", text: JSON.stringify(listPages(db, rp(project_id) ?? project_id)) }]
|
|
164
241
|
}))
|
|
165
242
|
|
|
166
243
|
server.tool("list_issues", {
|
|
167
244
|
project_id: z.string().optional(), status: z.string().optional(), limit: z.number().optional(),
|
|
168
245
|
}, ({ project_id, status, limit }) => ({
|
|
169
|
-
content: [{ type: "text", text: JSON.stringify(listIssues(db, project_id, status, limit ?? 50)) }]
|
|
246
|
+
content: [{ type: "text", text: JSON.stringify(listIssues(db, rp(project_id), status, limit ?? 50)) }]
|
|
170
247
|
}))
|
|
171
248
|
|
|
172
249
|
server.tool("resolve_issue", {
|
|
@@ -180,12 +257,12 @@ server.tool("create_alert_rule", {
|
|
|
180
257
|
level: z.string().optional(), service: z.string().optional(),
|
|
181
258
|
threshold_count: z.number().optional(), window_seconds: z.number().optional(),
|
|
182
259
|
action: z.enum(["webhook", "log"]).optional(), webhook_url: z.string().optional(),
|
|
183
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, args)) }] }))
|
|
260
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }))
|
|
184
261
|
|
|
185
262
|
server.tool("list_alert_rules", {
|
|
186
263
|
project_id: z.string().optional(),
|
|
187
264
|
}, ({ project_id }) => ({
|
|
188
|
-
content: [{ type: "text", text: JSON.stringify(listAlertRules(db, project_id)) }]
|
|
265
|
+
content: [{ type: "text", text: JSON.stringify(listAlertRules(db, rp(project_id))) }]
|
|
189
266
|
}))
|
|
190
267
|
|
|
191
268
|
server.tool("delete_alert_rule", { id: z.string() }, ({ id }) => {
|
|
@@ -193,14 +270,6 @@ server.tool("delete_alert_rule", { id: z.string() }, ({ id }) => {
|
|
|
193
270
|
return { content: [{ type: "text", text: "deleted" }] }
|
|
194
271
|
})
|
|
195
272
|
|
|
196
|
-
server.tool("log_session_context", {
|
|
197
|
-
session_id: z.string(),
|
|
198
|
-
brief: z.boolean().optional(),
|
|
199
|
-
}, async ({ session_id, brief }) => {
|
|
200
|
-
const ctx = await getSessionContext(db, session_id)
|
|
201
|
-
return { content: [{ type: "text", text: JSON.stringify({ ...ctx, logs: applyBrief(ctx.logs, brief !== false) }) }] }
|
|
202
|
-
})
|
|
203
|
-
|
|
204
273
|
server.tool("get_health", {}, () => ({
|
|
205
274
|
content: [{ type: "text", text: JSON.stringify(getHealth(db)) }]
|
|
206
275
|
}))
|
|
@@ -4,6 +4,9 @@ import { ingestBatch, ingestLog } from "../../lib/ingest.ts"
|
|
|
4
4
|
import { getLogContext, searchLogs, tailLogs } from "../../lib/query.ts"
|
|
5
5
|
import { summarizeLogs } from "../../lib/summarize.ts"
|
|
6
6
|
import { exportToCsv, exportToJson } from "../../lib/export.ts"
|
|
7
|
+
import { countLogs } from "../../lib/count.ts"
|
|
8
|
+
import { parseTime } from "../../lib/parse-time.ts"
|
|
9
|
+
import { resolveProjectId } from "../../lib/projects.ts"
|
|
7
10
|
import type { LogEntry, LogLevel } from "../../types/index.ts"
|
|
8
11
|
|
|
9
12
|
export function logsRoutes(db: Database) {
|
|
@@ -52,10 +55,34 @@ export function logsRoutes(db: Database) {
|
|
|
52
55
|
// GET /api/logs/summary
|
|
53
56
|
app.get("/summary", (c) => {
|
|
54
57
|
const { project_id, since } = c.req.query()
|
|
55
|
-
const summary = summarizeLogs(db, project_id || undefined, since || undefined)
|
|
58
|
+
const summary = summarizeLogs(db, resolveProjectId(db, project_id) || undefined, parseTime(since) || since || undefined)
|
|
56
59
|
return c.json(summary)
|
|
57
60
|
})
|
|
58
61
|
|
|
62
|
+
// GET /api/logs/count
|
|
63
|
+
app.get("/count", (c) => {
|
|
64
|
+
const { project_id, service, level, since, until } = c.req.query()
|
|
65
|
+
return c.json(countLogs(db, {
|
|
66
|
+
project_id: resolveProjectId(db, project_id) || undefined,
|
|
67
|
+
service: service || undefined,
|
|
68
|
+
level: level || undefined,
|
|
69
|
+
since: since || undefined,
|
|
70
|
+
until: until || undefined,
|
|
71
|
+
}))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// GET /api/logs/recent-errors
|
|
75
|
+
app.get("/recent-errors", (c) => {
|
|
76
|
+
const { project_id, since, limit } = c.req.query()
|
|
77
|
+
const rows = searchLogs(db, {
|
|
78
|
+
project_id: resolveProjectId(db, project_id) || undefined,
|
|
79
|
+
level: ["error", "fatal"],
|
|
80
|
+
since: parseTime(since || "1h"),
|
|
81
|
+
limit: limit ? Number(limit) : 20,
|
|
82
|
+
})
|
|
83
|
+
return c.json(rows.map(r => ({ id: r.id, timestamp: r.timestamp, level: r.level, message: r.message, service: r.service, age_seconds: Math.floor((Date.now() - new Date(r.timestamp).getTime()) / 1000) })))
|
|
84
|
+
})
|
|
85
|
+
|
|
59
86
|
// GET /api/logs/:trace_id/context
|
|
60
87
|
app.get("/:trace_id/context", (c) => {
|
|
61
88
|
const rows = getLogContext(db, c.req.param("trace_id"))
|