@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 CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  runJob
5
- } from "../index-2p6ynjet.js";
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-77ss2sf4.js";
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-0qv7fvzt.js";
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-0qv7fvzt.js");
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-77ss2sf4.js";
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-0qv7fvzt.js";
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 top_errors = db.prepare(`
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 { project_id: projectId, window, top_errors, error_rate_by_service, failing_pages, perf_regressions, summary };
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.1.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) => ({ id: r.id, timestamp: r.timestamp, level: r.level, message: r.message, service: r.service }));
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
- log_push: "Push a log entry (level, message, project_id?, service?, trace_id?, metadata?)",
28531
- log_search: "Search logs (project_id?, level?, since?, text?, brief?=true, limit?)",
28532
- log_tail: "Recent logs (project_id?, n?, brief?=true)",
28533
- log_summary: "Error/warn counts by service (project_id?, since?)",
28534
- log_context: "All logs for a trace_id (trace_id, brief?=true)",
28535
- log_diagnose: "Full diagnosis: top errors, failing pages, perf regressions (project_id, since?)",
28536
- log_compare: "Compare two time windows for new/resolved errors and perf delta",
28537
- perf_snapshot: "Latest perf snapshot (project_id, page_id?)",
28538
- perf_trend: "Perf over time (project_id, page_id?, since?, limit?)",
28539
- scan_status: "Last scan jobs (project_id?)",
28540
- list_projects: "List all projects",
28541
- list_pages: "List pages for a project (project_id)",
28542
- list_issues: "List grouped error issues (project_id?, status?, limit?)",
28543
- resolve_issue: "Update issue status (id, status: open|resolved|ignored)",
28544
- create_alert_rule: "Create alert rule (project_id, name, level, threshold_count, window_seconds, webhook_url?)",
28545
- list_alert_rules: "List alert rules (project_id?)",
28546
- delete_alert_rule: "Delete alert rule (id)",
28547
- log_session_context: "Logs + session metadata for a session_id (requires SESSIONS_URL env)",
28548
- get_health: "Server health + DB stats",
28549
- search_tools: "Search tools by keyword (query)",
28550
- describe_tools: "List all tools"
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
- return { content: [{ type: "text", text: matches.map(([k, v]) => `${k}: ${v}`).join(`
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}: ${v}`).join(`
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, { ...args, level: args.level ? args.level.split(",") : undefined });
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
- const rows = getLogContext(db, trace_id);
28627
- return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] };
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
- }, ({ project_id, since }) => ({
28633
- content: [{ type: "text", text: JSON.stringify(diagnose(db, project_id, since)) }]
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
  }));
@@ -6,7 +6,10 @@ import {
6
6
  setPageAuth,
7
7
  setRetentionPolicy,
8
8
  startScheduler
9
- } from "../index-2p6ynjet.js";
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-77ss2sf4.js";
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-0qv7fvzt.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/logs",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Log aggregation + browser script + headless page scanner + performance monitoring for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
+ })
@@ -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
+ }
@@ -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 function diagnose(db: Database, projectId: string, since?: string): DiagnosisResult {
14
- const window = since ?? new Date(Date.now() - 24 * 3600 * 1000).toISOString()
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 { project_id: projectId, window, top_errors, error_rate_by_service, failing_pages, perf_regressions, summary }
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
+ }
@@ -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
+ }
@@ -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.1.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 => ({ id: r.id, timestamp: r.timestamp, level: r.level, message: r.message, service: r.service }))
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
- const TOOLS: Record<string, string> = {
31
- register_project: "Register a project (name, github_repo?, base_url?, description?)",
32
- register_page: "Register a page URL (project_id, url, path?, name?)",
33
- create_scan_job: "Schedule page scans (project_id, schedule, page_id?)",
34
- log_push: "Push a log entry (level, message, project_id?, service?, trace_id?, metadata?)",
35
- log_search: "Search logs (project_id?, level?, since?, text?, brief?=true, limit?)",
36
- log_tail: "Recent logs (project_id?, n?, brief?=true)",
37
- log_summary: "Error/warn counts by service (project_id?, since?)",
38
- log_context: "All logs for a trace_id (trace_id, brief?=true)",
39
- log_diagnose: "Full diagnosis: top errors, failing pages, perf regressions (project_id, since?)",
40
- log_compare: "Compare two time windows for new/resolved errors and perf delta",
41
- perf_snapshot: "Latest perf snapshot (project_id, page_id?)",
42
- perf_trend: "Perf over time (project_id, page_id?, since?, limit?)",
43
- scan_status: "Last scan jobs (project_id?)",
44
- list_projects: "List all projects",
45
- list_pages: "List pages for a project (project_id)",
46
- list_issues: "List grouped error issues (project_id?, status?, limit?)",
47
- resolve_issue: "Update issue status (id, status: open|resolved|ignored)",
48
- create_alert_rule: "Create alert rule (project_id, name, level, threshold_count, window_seconds, webhook_url?)",
49
- list_alert_rules: "List alert rules (project_id?)",
50
- delete_alert_rule: "Delete alert rule (id)",
51
- log_session_context: "Logs + session metadata for a session_id (requires SESSIONS_URL env)",
52
- get_health: "Server health + DB stats",
53
- search_tools: "Search tools by keyword (query)",
54
- describe_tools: "List all tools",
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
- return { content: [{ type: "text", text: matches.map(([k, v]) => `${k}: ${v}`).join("\n") || "No matches" }] }
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}: ${v}`).join("\n") }]
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
- service: z.string().optional(),
84
- trace_id: z.string().optional(),
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, { ...args, level: args.level ? (args.level.split(",") as LogLevel[]) : undefined })
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
- const rows = getLogContext(db, trace_id)
122
- return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
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(), since: z.string().optional(),
127
- }, ({ project_id, since }) => ({
128
- content: [{ type: "text", text: JSON.stringify(diagnose(db, project_id, since)) }]
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, a_since, a_until, b_since, b_until)) }]
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"))