@hasna/logs 0.2.0 → 0.3.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/dist/cli/index.js +4 -4
- package/dist/mcp/index.js +164 -71
- package/dist/server/index.js +29 -4
- 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/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
import {
|
|
4
4
|
runJob
|
|
5
|
-
} from "../index-
|
|
5
|
+
} from "../index-c1rwjhff.js";
|
|
6
6
|
import {
|
|
7
7
|
createPage,
|
|
8
8
|
createProject,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
listPages,
|
|
12
12
|
listProjects,
|
|
13
13
|
summarizeLogs
|
|
14
|
-
} from "../index-
|
|
14
|
+
} from "../index-34xx795x.js";
|
|
15
15
|
import {
|
|
16
16
|
createJob,
|
|
17
17
|
listJobs
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import {
|
|
20
20
|
searchLogs,
|
|
21
21
|
tailLogs
|
|
22
|
-
} from "../query-
|
|
22
|
+
} from "../query-d5b0chp4.js";
|
|
23
23
|
import {
|
|
24
24
|
__commonJS,
|
|
25
25
|
__require,
|
|
@@ -2246,7 +2246,7 @@ program2.command("scan").description("Run an immediate scan for a job").option("
|
|
|
2246
2246
|
});
|
|
2247
2247
|
program2.command("watch").description("Stream new logs in real time with color coding").option("--project <id>").option("--level <levels>", "Comma-separated levels").option("--service <name>").action(async (opts) => {
|
|
2248
2248
|
const db = getDb();
|
|
2249
|
-
const { searchLogs: searchLogs2 } = await import("../query-
|
|
2249
|
+
const { searchLogs: searchLogs2 } = await import("../query-d5b0chp4.js");
|
|
2250
2250
|
const COLORS = {
|
|
2251
2251
|
debug: "\x1B[90m",
|
|
2252
2252
|
info: "\x1B[36m",
|
package/dist/mcp/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
+
import {
|
|
4
|
+
countLogs
|
|
5
|
+
} from "../index-n8qd55mt.js";
|
|
3
6
|
import {
|
|
4
7
|
createAlertRule,
|
|
5
8
|
createPage,
|
|
@@ -8,24 +11,28 @@ import {
|
|
|
8
11
|
getDb,
|
|
9
12
|
getLatestSnapshot,
|
|
10
13
|
getPerfTrend,
|
|
14
|
+
ingestBatch,
|
|
11
15
|
ingestLog,
|
|
12
16
|
listAlertRules,
|
|
13
17
|
listIssues,
|
|
14
18
|
listPages,
|
|
15
19
|
listProjects,
|
|
20
|
+
resolveProjectId,
|
|
16
21
|
scoreLabel,
|
|
17
22
|
summarizeLogs,
|
|
18
23
|
updateIssueStatus
|
|
19
|
-
} from "../index-
|
|
24
|
+
} from "../index-34xx795x.js";
|
|
20
25
|
import {
|
|
21
26
|
createJob,
|
|
22
27
|
listJobs
|
|
23
28
|
} from "../jobs-124e878j.js";
|
|
24
29
|
import {
|
|
25
30
|
getLogContext,
|
|
31
|
+
getLogContextFromId,
|
|
32
|
+
parseTime,
|
|
26
33
|
searchLogs,
|
|
27
34
|
tailLogs
|
|
28
|
-
} from "../query-
|
|
35
|
+
} from "../query-d5b0chp4.js";
|
|
29
36
|
import {
|
|
30
37
|
getHealth
|
|
31
38
|
} from "../health-f2qrebqc.js";
|
|
@@ -28381,17 +28388,19 @@ class StdioServerTransport {
|
|
|
28381
28388
|
}
|
|
28382
28389
|
|
|
28383
28390
|
// src/lib/diagnose.ts
|
|
28384
|
-
function diagnose(db, projectId, since) {
|
|
28385
|
-
const window = since ?? new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
|
28386
|
-
const
|
|
28391
|
+
function diagnose(db, projectId, since, include) {
|
|
28392
|
+
const window = parseTime(since) ?? since ?? new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
|
28393
|
+
const all = !include || include.length === 0;
|
|
28394
|
+
const want = (k) => all || include.includes(k);
|
|
28395
|
+
const top_errors = want("top_errors") ? db.prepare(`
|
|
28387
28396
|
SELECT message, COUNT(*) as count, service, MAX(timestamp) as last_seen
|
|
28388
28397
|
FROM logs
|
|
28389
28398
|
WHERE project_id = $p AND level IN ('error','fatal') AND timestamp >= $since
|
|
28390
28399
|
GROUP BY message, service
|
|
28391
28400
|
ORDER BY count DESC
|
|
28392
28401
|
LIMIT 10
|
|
28393
|
-
`).all({ $p: projectId, $since: window });
|
|
28394
|
-
const error_rate_by_service = db.prepare(`
|
|
28402
|
+
`).all({ $p: projectId, $since: window }) : [];
|
|
28403
|
+
const error_rate_by_service = want("error_rate") ? db.prepare(`
|
|
28395
28404
|
SELECT service,
|
|
28396
28405
|
SUM(CASE WHEN level IN ('error','fatal') THEN 1 ELSE 0 END) as errors,
|
|
28397
28406
|
SUM(CASE WHEN level = 'warn' THEN 1 ELSE 0 END) as warns,
|
|
@@ -28400,8 +28409,8 @@ function diagnose(db, projectId, since) {
|
|
|
28400
28409
|
WHERE project_id = $p AND timestamp >= $since
|
|
28401
28410
|
GROUP BY service
|
|
28402
28411
|
ORDER BY errors DESC
|
|
28403
|
-
`).all({ $p: projectId, $since: window });
|
|
28404
|
-
const failing_pages = db.prepare(`
|
|
28412
|
+
`).all({ $p: projectId, $since: window }) : [];
|
|
28413
|
+
const failing_pages = want("failing_pages") ? db.prepare(`
|
|
28405
28414
|
SELECT l.page_id, p.url, COUNT(*) as error_count
|
|
28406
28415
|
FROM logs l
|
|
28407
28416
|
JOIN pages p ON p.id = l.page_id
|
|
@@ -28409,8 +28418,8 @@ function diagnose(db, projectId, since) {
|
|
|
28409
28418
|
GROUP BY l.page_id, p.url
|
|
28410
28419
|
ORDER BY error_count DESC
|
|
28411
28420
|
LIMIT 10
|
|
28412
|
-
`).all({ $p: projectId, $since: window });
|
|
28413
|
-
const perf_regressions = db.prepare(`
|
|
28421
|
+
`).all({ $p: projectId, $since: window }) : [];
|
|
28422
|
+
const perf_regressions = want("perf") ? db.prepare(`
|
|
28414
28423
|
SELECT * FROM (
|
|
28415
28424
|
SELECT
|
|
28416
28425
|
cur.page_id,
|
|
@@ -28427,11 +28436,25 @@ function diagnose(db, projectId, since) {
|
|
|
28427
28436
|
) WHERE delta < -5 OR delta IS NULL
|
|
28428
28437
|
ORDER BY delta ASC
|
|
28429
28438
|
LIMIT 10
|
|
28430
|
-
`).all({ $p: projectId });
|
|
28439
|
+
`).all({ $p: projectId }) : [];
|
|
28431
28440
|
const totalErrors = top_errors.reduce((s, e) => s + e.count, 0);
|
|
28441
|
+
const totalWarns = error_rate_by_service.reduce((s, r) => s + r.warns, 0);
|
|
28432
28442
|
const topService = error_rate_by_service[0];
|
|
28443
|
+
const score = totalErrors === 0 ? "green" : totalErrors <= 10 ? "yellow" : "red";
|
|
28433
28444
|
const summary = totalErrors === 0 ? "No errors in this window. All looks good." : `${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).`;
|
|
28434
|
-
return {
|
|
28445
|
+
return {
|
|
28446
|
+
project_id: projectId,
|
|
28447
|
+
window,
|
|
28448
|
+
score,
|
|
28449
|
+
error_count: totalErrors,
|
|
28450
|
+
warn_count: totalWarns,
|
|
28451
|
+
has_perf_regression: perf_regressions.length > 0,
|
|
28452
|
+
top_errors,
|
|
28453
|
+
error_rate_by_service,
|
|
28454
|
+
failing_pages,
|
|
28455
|
+
perf_regressions,
|
|
28456
|
+
summary
|
|
28457
|
+
};
|
|
28435
28458
|
}
|
|
28436
28459
|
|
|
28437
28460
|
// src/lib/compare.ts
|
|
@@ -28517,48 +28540,71 @@ async function getSessionContext(db, sessionId) {
|
|
|
28517
28540
|
|
|
28518
28541
|
// src/mcp/index.ts
|
|
28519
28542
|
var db = getDb();
|
|
28520
|
-
var server = new McpServer({ name: "logs", version: "0.
|
|
28543
|
+
var server = new McpServer({ name: "logs", version: "0.3.0" });
|
|
28521
28544
|
function applyBrief(rows, brief = true) {
|
|
28522
28545
|
if (!brief)
|
|
28523
28546
|
return rows;
|
|
28524
|
-
return rows.map((r) => ({
|
|
28547
|
+
return rows.map((r) => ({
|
|
28548
|
+
id: r.id,
|
|
28549
|
+
timestamp: r.timestamp,
|
|
28550
|
+
level: r.level,
|
|
28551
|
+
message: r.message,
|
|
28552
|
+
service: r.service,
|
|
28553
|
+
age_seconds: Math.floor((Date.now() - new Date(r.timestamp).getTime()) / 1000)
|
|
28554
|
+
}));
|
|
28555
|
+
}
|
|
28556
|
+
function rp(idOrName) {
|
|
28557
|
+
if (!idOrName)
|
|
28558
|
+
return;
|
|
28559
|
+
return resolveProjectId(db, idOrName) ?? idOrName;
|
|
28525
28560
|
}
|
|
28526
28561
|
var TOOLS = {
|
|
28527
|
-
register_project: "Register a project (name, github_repo?, base_url?, description?)",
|
|
28528
|
-
register_page: "Register a page URL (project_id, url, path?, name?)",
|
|
28529
|
-
create_scan_job: "Schedule page scans (project_id, schedule, page_id?)",
|
|
28530
|
-
|
|
28531
|
-
|
|
28532
|
-
|
|
28533
|
-
|
|
28534
|
-
|
|
28535
|
-
|
|
28536
|
-
|
|
28537
|
-
|
|
28538
|
-
|
|
28539
|
-
|
|
28540
|
-
|
|
28541
|
-
|
|
28542
|
-
|
|
28543
|
-
|
|
28544
|
-
|
|
28545
|
-
|
|
28546
|
-
|
|
28547
|
-
|
|
28548
|
-
|
|
28549
|
-
|
|
28550
|
-
|
|
28562
|
+
register_project: { desc: "Register a project", params: "(name, github_repo?, base_url?, description?)" },
|
|
28563
|
+
register_page: { desc: "Register a page URL to a project", params: "(project_id, url, path?, name?)" },
|
|
28564
|
+
create_scan_job: { desc: "Schedule headless page scans", params: "(project_id, schedule, page_id?)" },
|
|
28565
|
+
resolve_project: { desc: "Resolve project name to ID", params: "(name)" },
|
|
28566
|
+
log_push: { desc: "Push a single log entry", params: "(level, message, project_id?, service?, trace_id?, metadata?)" },
|
|
28567
|
+
log_push_batch: { desc: "Push multiple log entries in one call", params: "(entries: Array<{level, message, project_id?, service?, trace_id?}>)" },
|
|
28568
|
+
log_search: { desc: "Search logs", params: "(project_id?, level?, since?, until?, text?, service?, limit?=100, brief?=true)" },
|
|
28569
|
+
log_tail: { desc: "Get N most recent logs", params: "(project_id?, n?=50, brief?=true)" },
|
|
28570
|
+
log_count: { desc: "Count logs \u2014 zero token cost, pure signal", params: "(project_id?, service?, level?, since?, until?)" },
|
|
28571
|
+
log_recent_errors: { desc: "Shortcut: recent errors + fatals", params: "(project_id?, since?='1h', limit?=20)" },
|
|
28572
|
+
log_summary: { desc: "Error/warn counts by service", params: "(project_id?, since?)" },
|
|
28573
|
+
log_context: { desc: "All logs for a trace_id", params: "(trace_id, brief?=true)" },
|
|
28574
|
+
log_context_from_id: { desc: "Trace context from a log ID (no trace_id needed)", params: "(log_id, brief?=true)" },
|
|
28575
|
+
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'])" },
|
|
28576
|
+
log_compare: { desc: "Diff two time windows for new/resolved errors", params: "(project_id, a_since, a_until, b_since, b_until)" },
|
|
28577
|
+
log_session_context: { desc: "Logs + session metadata for a session_id", params: "(session_id, brief?=true)" },
|
|
28578
|
+
perf_snapshot: { desc: "Latest performance snapshot", params: "(project_id, page_id?)" },
|
|
28579
|
+
perf_trend: { desc: "Performance over time", params: "(project_id, page_id?, since?, limit?=50)" },
|
|
28580
|
+
scan_status: { desc: "Last scan jobs", params: "(project_id?)" },
|
|
28581
|
+
list_projects: { desc: "List all projects", params: "()" },
|
|
28582
|
+
list_pages: { desc: "List pages for a project", params: "(project_id)" },
|
|
28583
|
+
list_issues: { desc: "List grouped error issues", params: "(project_id?, status?, limit?=50)" },
|
|
28584
|
+
resolve_issue: { desc: "Update issue status", params: "(id, status: open|resolved|ignored)" },
|
|
28585
|
+
create_alert_rule: { desc: "Create alert rule", params: "(project_id, name, level?, threshold_count?, window_seconds?, webhook_url?)" },
|
|
28586
|
+
list_alert_rules: { desc: "List alert rules", params: "(project_id?)" },
|
|
28587
|
+
delete_alert_rule: { desc: "Delete alert rule", params: "(id)" },
|
|
28588
|
+
get_health: { desc: "Server health + DB stats", params: "()" },
|
|
28589
|
+
search_tools: { desc: "Search tools by keyword \u2014 returns names, descriptions, param signatures", params: "(query)" },
|
|
28590
|
+
describe_tools: { desc: "List all tools with descriptions and param signatures", params: "()" }
|
|
28551
28591
|
};
|
|
28552
28592
|
server.tool("search_tools", { query: exports_external.string() }, ({ query }) => {
|
|
28553
28593
|
const q = query.toLowerCase();
|
|
28554
|
-
const matches = Object.entries(TOOLS).filter(([k, v]) => k.includes(q) || v.toLowerCase().includes(q));
|
|
28555
|
-
|
|
28556
|
-
`) || "No matches"
|
|
28594
|
+
const matches = Object.entries(TOOLS).filter(([k, v]) => k.includes(q) || v.desc.toLowerCase().includes(q));
|
|
28595
|
+
const text = matches.map(([k, v]) => `${k}${v.params} \u2014 ${v.desc}`).join(`
|
|
28596
|
+
`) || "No matches";
|
|
28597
|
+
return { content: [{ type: "text", text }] };
|
|
28557
28598
|
});
|
|
28558
28599
|
server.tool("describe_tools", {}, () => ({
|
|
28559
|
-
content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}
|
|
28600
|
+
content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}${v.params} \u2014 ${v.desc}`).join(`
|
|
28560
28601
|
`) }]
|
|
28561
28602
|
}));
|
|
28603
|
+
server.tool("resolve_project", { name: exports_external.string() }, ({ name }) => {
|
|
28604
|
+
const id = resolveProjectId(db, name);
|
|
28605
|
+
const project = id ? db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: id }) : null;
|
|
28606
|
+
return { content: [{ type: "text", text: JSON.stringify(project ?? { error: `Project '${name}' not found` }) }] };
|
|
28607
|
+
});
|
|
28562
28608
|
server.tool("register_project", {
|
|
28563
28609
|
name: exports_external.string(),
|
|
28564
28610
|
github_repo: exports_external.string().optional(),
|
|
@@ -28570,12 +28616,12 @@ server.tool("register_page", {
|
|
|
28570
28616
|
url: exports_external.string(),
|
|
28571
28617
|
path: exports_external.string().optional(),
|
|
28572
28618
|
name: exports_external.string().optional()
|
|
28573
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, args)) }] }));
|
|
28619
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }));
|
|
28574
28620
|
server.tool("create_scan_job", {
|
|
28575
28621
|
project_id: exports_external.string(),
|
|
28576
28622
|
schedule: exports_external.string(),
|
|
28577
28623
|
page_id: exports_external.string().optional()
|
|
28578
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, args)) }] }));
|
|
28624
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }));
|
|
28579
28625
|
server.tool("log_push", {
|
|
28580
28626
|
level: exports_external.enum(["debug", "info", "warn", "error", "fatal"]),
|
|
28581
28627
|
message: exports_external.string(),
|
|
@@ -28587,9 +28633,22 @@ server.tool("log_push", {
|
|
|
28587
28633
|
url: exports_external.string().optional(),
|
|
28588
28634
|
metadata: exports_external.record(exports_external.unknown()).optional()
|
|
28589
28635
|
}, (args) => {
|
|
28590
|
-
const row = ingestLog(db, args);
|
|
28636
|
+
const row = ingestLog(db, { ...args, project_id: rp(args.project_id) });
|
|
28591
28637
|
return { content: [{ type: "text", text: `Logged: ${row.id}` }] };
|
|
28592
28638
|
});
|
|
28639
|
+
server.tool("log_push_batch", {
|
|
28640
|
+
entries: exports_external.array(exports_external.object({
|
|
28641
|
+
level: exports_external.enum(["debug", "info", "warn", "error", "fatal"]),
|
|
28642
|
+
message: exports_external.string(),
|
|
28643
|
+
project_id: exports_external.string().optional(),
|
|
28644
|
+
service: exports_external.string().optional(),
|
|
28645
|
+
trace_id: exports_external.string().optional(),
|
|
28646
|
+
metadata: exports_external.record(exports_external.unknown()).optional()
|
|
28647
|
+
}))
|
|
28648
|
+
}, ({ entries }) => {
|
|
28649
|
+
const rows = ingestBatch(db, entries.map((e) => ({ ...e, project_id: rp(e.project_id) })));
|
|
28650
|
+
return { content: [{ type: "text", text: `Logged ${rows.length} entries` }] };
|
|
28651
|
+
});
|
|
28593
28652
|
server.tool("log_search", {
|
|
28594
28653
|
project_id: exports_external.string().optional(),
|
|
28595
28654
|
page_id: exports_external.string().optional(),
|
|
@@ -28602,7 +28661,13 @@ server.tool("log_search", {
|
|
|
28602
28661
|
limit: exports_external.number().optional(),
|
|
28603
28662
|
brief: exports_external.boolean().optional()
|
|
28604
28663
|
}, (args) => {
|
|
28605
|
-
const rows = searchLogs(db, {
|
|
28664
|
+
const rows = searchLogs(db, {
|
|
28665
|
+
...args,
|
|
28666
|
+
project_id: rp(args.project_id),
|
|
28667
|
+
level: args.level ? args.level.split(",") : undefined,
|
|
28668
|
+
since: parseTime(args.since) ?? args.since,
|
|
28669
|
+
until: parseTime(args.until) ?? args.until
|
|
28670
|
+
});
|
|
28606
28671
|
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, args.brief !== false)) }] };
|
|
28607
28672
|
});
|
|
28608
28673
|
server.tool("log_tail", {
|
|
@@ -28610,27 +28675,55 @@ server.tool("log_tail", {
|
|
|
28610
28675
|
n: exports_external.number().optional(),
|
|
28611
28676
|
brief: exports_external.boolean().optional()
|
|
28612
28677
|
}, ({ project_id, n, brief }) => {
|
|
28613
|
-
const rows = tailLogs(db, project_id, n ?? 50);
|
|
28678
|
+
const rows = tailLogs(db, rp(project_id), n ?? 50);
|
|
28614
28679
|
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] };
|
|
28615
28680
|
});
|
|
28681
|
+
server.tool("log_count", {
|
|
28682
|
+
project_id: exports_external.string().optional(),
|
|
28683
|
+
service: exports_external.string().optional(),
|
|
28684
|
+
level: exports_external.string().optional(),
|
|
28685
|
+
since: exports_external.string().optional(),
|
|
28686
|
+
until: exports_external.string().optional()
|
|
28687
|
+
}, (args) => ({
|
|
28688
|
+
content: [{ type: "text", text: JSON.stringify(countLogs(db, { ...args, project_id: rp(args.project_id) })) }]
|
|
28689
|
+
}));
|
|
28690
|
+
server.tool("log_recent_errors", {
|
|
28691
|
+
project_id: exports_external.string().optional(),
|
|
28692
|
+
since: exports_external.string().optional(),
|
|
28693
|
+
limit: exports_external.number().optional()
|
|
28694
|
+
}, ({ project_id, since, limit }) => {
|
|
28695
|
+
const rows = searchLogs(db, {
|
|
28696
|
+
project_id: rp(project_id),
|
|
28697
|
+
level: ["error", "fatal"],
|
|
28698
|
+
since: parseTime(since ?? "1h"),
|
|
28699
|
+
limit: limit ?? 20
|
|
28700
|
+
});
|
|
28701
|
+
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, true)) }] };
|
|
28702
|
+
});
|
|
28616
28703
|
server.tool("log_summary", {
|
|
28617
28704
|
project_id: exports_external.string().optional(),
|
|
28618
28705
|
since: exports_external.string().optional()
|
|
28619
28706
|
}, ({ project_id, since }) => ({
|
|
28620
|
-
content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, project_id, since)) }]
|
|
28707
|
+
content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, rp(project_id), parseTime(since) ?? since)) }]
|
|
28621
28708
|
}));
|
|
28622
28709
|
server.tool("log_context", {
|
|
28623
28710
|
trace_id: exports_external.string(),
|
|
28624
28711
|
brief: exports_external.boolean().optional()
|
|
28625
|
-
}, ({ trace_id, brief }) => {
|
|
28626
|
-
|
|
28627
|
-
|
|
28628
|
-
|
|
28712
|
+
}, ({ trace_id, brief }) => ({
|
|
28713
|
+
content: [{ type: "text", text: JSON.stringify(applyBrief(getLogContext(db, trace_id), brief !== false)) }]
|
|
28714
|
+
}));
|
|
28715
|
+
server.tool("log_context_from_id", {
|
|
28716
|
+
log_id: exports_external.string(),
|
|
28717
|
+
brief: exports_external.boolean().optional()
|
|
28718
|
+
}, ({ log_id, brief }) => ({
|
|
28719
|
+
content: [{ type: "text", text: JSON.stringify(applyBrief(getLogContextFromId(db, log_id), brief !== false)) }]
|
|
28720
|
+
}));
|
|
28629
28721
|
server.tool("log_diagnose", {
|
|
28630
28722
|
project_id: exports_external.string(),
|
|
28631
|
-
since: exports_external.string().optional()
|
|
28632
|
-
|
|
28633
|
-
|
|
28723
|
+
since: exports_external.string().optional(),
|
|
28724
|
+
include: exports_external.array(exports_external.enum(["top_errors", "error_rate", "failing_pages", "perf"])).optional()
|
|
28725
|
+
}, ({ project_id, since, include }) => ({
|
|
28726
|
+
content: [{ type: "text", text: JSON.stringify(diagnose(db, rp(project_id) ?? project_id, since, include)) }]
|
|
28634
28727
|
}));
|
|
28635
28728
|
server.tool("log_compare", {
|
|
28636
28729
|
project_id: exports_external.string(),
|
|
@@ -28639,13 +28732,20 @@ server.tool("log_compare", {
|
|
|
28639
28732
|
b_since: exports_external.string(),
|
|
28640
28733
|
b_until: exports_external.string()
|
|
28641
28734
|
}, ({ project_id, a_since, a_until, b_since, b_until }) => ({
|
|
28642
|
-
content: [{ type: "text", text: JSON.stringify(compare(db, project_id, a_since, a_until, b_since, b_until)) }]
|
|
28735
|
+
content: [{ type: "text", text: JSON.stringify(compare(db, rp(project_id) ?? project_id, parseTime(a_since) ?? a_since, parseTime(a_until) ?? a_until, parseTime(b_since) ?? b_since, parseTime(b_until) ?? b_until)) }]
|
|
28643
28736
|
}));
|
|
28737
|
+
server.tool("log_session_context", {
|
|
28738
|
+
session_id: exports_external.string(),
|
|
28739
|
+
brief: exports_external.boolean().optional()
|
|
28740
|
+
}, async ({ session_id, brief }) => {
|
|
28741
|
+
const ctx = await getSessionContext(db, session_id);
|
|
28742
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...ctx, logs: applyBrief(ctx.logs, brief !== false) }) }] };
|
|
28743
|
+
});
|
|
28644
28744
|
server.tool("perf_snapshot", {
|
|
28645
28745
|
project_id: exports_external.string(),
|
|
28646
28746
|
page_id: exports_external.string().optional()
|
|
28647
28747
|
}, ({ project_id, page_id }) => {
|
|
28648
|
-
const snap = getLatestSnapshot(db, project_id, page_id);
|
|
28748
|
+
const snap = getLatestSnapshot(db, rp(project_id) ?? project_id, page_id);
|
|
28649
28749
|
return { content: [{ type: "text", text: JSON.stringify(snap ? { ...snap, label: scoreLabel(snap.score) } : null) }] };
|
|
28650
28750
|
});
|
|
28651
28751
|
server.tool("perf_trend", {
|
|
@@ -28654,25 +28754,25 @@ server.tool("perf_trend", {
|
|
|
28654
28754
|
since: exports_external.string().optional(),
|
|
28655
28755
|
limit: exports_external.number().optional()
|
|
28656
28756
|
}, ({ project_id, page_id, since, limit }) => ({
|
|
28657
|
-
content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, project_id, page_id, since, limit ?? 50)) }]
|
|
28757
|
+
content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, rp(project_id) ?? project_id, page_id, parseTime(since) ?? since, limit ?? 50)) }]
|
|
28658
28758
|
}));
|
|
28659
28759
|
server.tool("scan_status", {
|
|
28660
28760
|
project_id: exports_external.string().optional()
|
|
28661
28761
|
}, ({ project_id }) => ({
|
|
28662
|
-
content: [{ type: "text", text: JSON.stringify(listJobs(db, project_id)) }]
|
|
28762
|
+
content: [{ type: "text", text: JSON.stringify(listJobs(db, rp(project_id))) }]
|
|
28663
28763
|
}));
|
|
28664
28764
|
server.tool("list_projects", {}, () => ({
|
|
28665
28765
|
content: [{ type: "text", text: JSON.stringify(listProjects(db)) }]
|
|
28666
28766
|
}));
|
|
28667
28767
|
server.tool("list_pages", { project_id: exports_external.string() }, ({ project_id }) => ({
|
|
28668
|
-
content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }]
|
|
28768
|
+
content: [{ type: "text", text: JSON.stringify(listPages(db, rp(project_id) ?? project_id)) }]
|
|
28669
28769
|
}));
|
|
28670
28770
|
server.tool("list_issues", {
|
|
28671
28771
|
project_id: exports_external.string().optional(),
|
|
28672
28772
|
status: exports_external.string().optional(),
|
|
28673
28773
|
limit: exports_external.number().optional()
|
|
28674
28774
|
}, ({ project_id, status, limit }) => ({
|
|
28675
|
-
content: [{ type: "text", text: JSON.stringify(listIssues(db, project_id, status, limit ?? 50)) }]
|
|
28775
|
+
content: [{ type: "text", text: JSON.stringify(listIssues(db, rp(project_id), status, limit ?? 50)) }]
|
|
28676
28776
|
}));
|
|
28677
28777
|
server.tool("resolve_issue", {
|
|
28678
28778
|
id: exports_external.string(),
|
|
@@ -28689,23 +28789,16 @@ server.tool("create_alert_rule", {
|
|
|
28689
28789
|
window_seconds: exports_external.number().optional(),
|
|
28690
28790
|
action: exports_external.enum(["webhook", "log"]).optional(),
|
|
28691
28791
|
webhook_url: exports_external.string().optional()
|
|
28692
|
-
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, args)) }] }));
|
|
28792
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, { ...args, project_id: rp(args.project_id) ?? args.project_id })) }] }));
|
|
28693
28793
|
server.tool("list_alert_rules", {
|
|
28694
28794
|
project_id: exports_external.string().optional()
|
|
28695
28795
|
}, ({ project_id }) => ({
|
|
28696
|
-
content: [{ type: "text", text: JSON.stringify(listAlertRules(db, project_id)) }]
|
|
28796
|
+
content: [{ type: "text", text: JSON.stringify(listAlertRules(db, rp(project_id))) }]
|
|
28697
28797
|
}));
|
|
28698
28798
|
server.tool("delete_alert_rule", { id: exports_external.string() }, ({ id }) => {
|
|
28699
28799
|
deleteAlertRule(db, id);
|
|
28700
28800
|
return { content: [{ type: "text", text: "deleted" }] };
|
|
28701
28801
|
});
|
|
28702
|
-
server.tool("log_session_context", {
|
|
28703
|
-
session_id: exports_external.string(),
|
|
28704
|
-
brief: exports_external.boolean().optional()
|
|
28705
|
-
}, async ({ session_id, brief }) => {
|
|
28706
|
-
const ctx = await getSessionContext(db, session_id);
|
|
28707
|
-
return { content: [{ type: "text", text: JSON.stringify({ ...ctx, logs: applyBrief(ctx.logs, brief !== false) }) }] };
|
|
28708
|
-
});
|
|
28709
28802
|
server.tool("get_health", {}, () => ({
|
|
28710
28803
|
content: [{ type: "text", text: JSON.stringify(getHealth(db)) }]
|
|
28711
28804
|
}));
|
package/dist/server/index.js
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
setPageAuth,
|
|
7
7
|
setRetentionPolicy,
|
|
8
8
|
startScheduler
|
|
9
|
-
} from "../index-
|
|
9
|
+
} from "../index-c1rwjhff.js";
|
|
10
|
+
import {
|
|
11
|
+
countLogs
|
|
12
|
+
} from "../index-n8qd55mt.js";
|
|
10
13
|
import {
|
|
11
14
|
createAlertRule,
|
|
12
15
|
createPage,
|
|
@@ -23,11 +26,12 @@ import {
|
|
|
23
26
|
listIssues,
|
|
24
27
|
listPages,
|
|
25
28
|
listProjects,
|
|
29
|
+
resolveProjectId,
|
|
26
30
|
summarizeLogs,
|
|
27
31
|
updateAlertRule,
|
|
28
32
|
updateIssueStatus,
|
|
29
33
|
updateProject
|
|
30
|
-
} from "../index-
|
|
34
|
+
} from "../index-34xx795x.js";
|
|
31
35
|
import {
|
|
32
36
|
createJob,
|
|
33
37
|
deleteJob,
|
|
@@ -36,9 +40,10 @@ import {
|
|
|
36
40
|
} from "../jobs-124e878j.js";
|
|
37
41
|
import {
|
|
38
42
|
getLogContext,
|
|
43
|
+
parseTime,
|
|
39
44
|
searchLogs,
|
|
40
45
|
tailLogs
|
|
41
|
-
} from "../query-
|
|
46
|
+
} from "../query-d5b0chp4.js";
|
|
42
47
|
import {
|
|
43
48
|
exportToCsv,
|
|
44
49
|
exportToJson
|
|
@@ -2097,9 +2102,29 @@ function logsRoutes(db) {
|
|
|
2097
2102
|
});
|
|
2098
2103
|
app.get("/summary", (c) => {
|
|
2099
2104
|
const { project_id, since } = c.req.query();
|
|
2100
|
-
const summary = summarizeLogs(db, project_id || undefined, since || undefined);
|
|
2105
|
+
const summary = summarizeLogs(db, resolveProjectId(db, project_id) || undefined, parseTime(since) || since || undefined);
|
|
2101
2106
|
return c.json(summary);
|
|
2102
2107
|
});
|
|
2108
|
+
app.get("/count", (c) => {
|
|
2109
|
+
const { project_id, service, level, since, until } = c.req.query();
|
|
2110
|
+
return c.json(countLogs(db, {
|
|
2111
|
+
project_id: resolveProjectId(db, project_id) || undefined,
|
|
2112
|
+
service: service || undefined,
|
|
2113
|
+
level: level || undefined,
|
|
2114
|
+
since: since || undefined,
|
|
2115
|
+
until: until || undefined
|
|
2116
|
+
}));
|
|
2117
|
+
});
|
|
2118
|
+
app.get("/recent-errors", (c) => {
|
|
2119
|
+
const { project_id, since, limit } = c.req.query();
|
|
2120
|
+
const rows = searchLogs(db, {
|
|
2121
|
+
project_id: resolveProjectId(db, project_id) || undefined,
|
|
2122
|
+
level: ["error", "fatal"],
|
|
2123
|
+
since: parseTime(since || "1h"),
|
|
2124
|
+
limit: limit ? Number(limit) : 20
|
|
2125
|
+
});
|
|
2126
|
+
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) })));
|
|
2127
|
+
});
|
|
2103
2128
|
app.get("/:trace_id/context", (c) => {
|
|
2104
2129
|
const rows = getLogContext(db, c.req.param("trace_id"));
|
|
2105
2130
|
return c.json(rows);
|
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
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
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"))
|