@hasna/logs 0.1.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.
Files changed (45) hide show
  1. package/dashboard/README.md +73 -0
  2. package/dashboard/bun.lock +526 -0
  3. package/dashboard/eslint.config.js +23 -0
  4. package/dashboard/index.html +13 -0
  5. package/dashboard/package.json +32 -0
  6. package/dashboard/public/favicon.svg +1 -0
  7. package/dashboard/public/icons.svg +24 -0
  8. package/dashboard/src/App.css +184 -0
  9. package/dashboard/src/App.tsx +49 -0
  10. package/dashboard/src/api.ts +33 -0
  11. package/dashboard/src/assets/hero.png +0 -0
  12. package/dashboard/src/assets/react.svg +1 -0
  13. package/dashboard/src/assets/vite.svg +1 -0
  14. package/dashboard/src/index.css +111 -0
  15. package/dashboard/src/main.tsx +10 -0
  16. package/dashboard/src/pages/Alerts.tsx +69 -0
  17. package/dashboard/src/pages/Issues.tsx +50 -0
  18. package/dashboard/src/pages/Perf.tsx +75 -0
  19. package/dashboard/src/pages/Projects.tsx +67 -0
  20. package/dashboard/src/pages/Summary.tsx +67 -0
  21. package/dashboard/src/pages/Tail.tsx +65 -0
  22. package/dashboard/tsconfig.app.json +28 -0
  23. package/dashboard/tsconfig.json +7 -0
  24. package/dashboard/tsconfig.node.json +26 -0
  25. package/dashboard/vite.config.ts +14 -0
  26. package/dist/cli/index.js +80 -9
  27. package/dist/mcp/index.js +217 -96
  28. package/dist/server/index.js +307 -7
  29. package/package.json +3 -1
  30. package/sdk/package.json +3 -2
  31. package/sdk/src/index.ts +1 -1
  32. package/sdk/src/types.ts +56 -0
  33. package/src/cli/index.ts +70 -4
  34. package/src/lib/count.test.ts +44 -0
  35. package/src/lib/count.ts +45 -0
  36. package/src/lib/diagnose.ts +26 -11
  37. package/src/lib/parse-time.test.ts +37 -0
  38. package/src/lib/parse-time.ts +14 -0
  39. package/src/lib/projects.ts +10 -0
  40. package/src/lib/query.ts +10 -2
  41. package/src/lib/session-context.ts +28 -0
  42. package/src/lib/summarize.ts +2 -1
  43. package/src/mcp/index.ts +138 -59
  44. package/src/server/index.ts +4 -1
  45. package/src/server/routes/logs.ts +28 -1
@@ -6,7 +6,10 @@ import {
6
6
  setPageAuth,
7
7
  setRetentionPolicy,
8
8
  startScheduler
9
- } from "../index-4x090f69.js";
9
+ } from "../index-c1rwjhff.js";
10
+ import {
11
+ countLogs
12
+ } from "../index-n8qd55mt.js";
10
13
  import {
11
14
  createAlertRule,
12
15
  createPage,
@@ -15,7 +18,6 @@ import {
15
18
  getDb,
16
19
  getIssue,
17
20
  getLatestSnapshot,
18
- getLogContext,
19
21
  getPerfTrend,
20
22
  getProject,
21
23
  ingestBatch,
@@ -24,19 +26,24 @@ import {
24
26
  listIssues,
25
27
  listPages,
26
28
  listProjects,
27
- searchLogs,
29
+ resolveProjectId,
28
30
  summarizeLogs,
29
- tailLogs,
30
31
  updateAlertRule,
31
32
  updateIssueStatus,
32
33
  updateProject
33
- } from "../index-qmsvtxax.js";
34
+ } from "../index-34xx795x.js";
34
35
  import {
35
36
  createJob,
36
37
  deleteJob,
37
38
  listJobs,
38
39
  updateJob
39
40
  } from "../jobs-124e878j.js";
41
+ import {
42
+ getLogContext,
43
+ parseTime,
44
+ searchLogs,
45
+ tailLogs
46
+ } from "../query-d5b0chp4.js";
40
47
  import {
41
48
  exportToCsv,
42
49
  exportToJson
@@ -1669,6 +1676,277 @@ var cors = (options) => {
1669
1676
  };
1670
1677
  };
1671
1678
 
1679
+ // node_modules/hono/dist/adapter/bun/serve-static.js
1680
+ import { stat } from "fs/promises";
1681
+ import { join } from "path";
1682
+
1683
+ // node_modules/hono/dist/utils/compress.js
1684
+ var COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i;
1685
+
1686
+ // node_modules/hono/dist/utils/mime.js
1687
+ var getMimeType = (filename, mimes = baseMimes) => {
1688
+ const regexp = /\.([a-zA-Z0-9]+?)$/;
1689
+ const match2 = filename.match(regexp);
1690
+ if (!match2) {
1691
+ return;
1692
+ }
1693
+ let mimeType = mimes[match2[1].toLowerCase()];
1694
+ if (mimeType && mimeType.startsWith("text")) {
1695
+ mimeType += "; charset=utf-8";
1696
+ }
1697
+ return mimeType;
1698
+ };
1699
+ var _baseMimes = {
1700
+ aac: "audio/aac",
1701
+ avi: "video/x-msvideo",
1702
+ avif: "image/avif",
1703
+ av1: "video/av1",
1704
+ bin: "application/octet-stream",
1705
+ bmp: "image/bmp",
1706
+ css: "text/css",
1707
+ csv: "text/csv",
1708
+ eot: "application/vnd.ms-fontobject",
1709
+ epub: "application/epub+zip",
1710
+ gif: "image/gif",
1711
+ gz: "application/gzip",
1712
+ htm: "text/html",
1713
+ html: "text/html",
1714
+ ico: "image/x-icon",
1715
+ ics: "text/calendar",
1716
+ jpeg: "image/jpeg",
1717
+ jpg: "image/jpeg",
1718
+ js: "text/javascript",
1719
+ json: "application/json",
1720
+ jsonld: "application/ld+json",
1721
+ map: "application/json",
1722
+ mid: "audio/x-midi",
1723
+ midi: "audio/x-midi",
1724
+ mjs: "text/javascript",
1725
+ mp3: "audio/mpeg",
1726
+ mp4: "video/mp4",
1727
+ mpeg: "video/mpeg",
1728
+ oga: "audio/ogg",
1729
+ ogv: "video/ogg",
1730
+ ogx: "application/ogg",
1731
+ opus: "audio/opus",
1732
+ otf: "font/otf",
1733
+ pdf: "application/pdf",
1734
+ png: "image/png",
1735
+ rtf: "application/rtf",
1736
+ svg: "image/svg+xml",
1737
+ tif: "image/tiff",
1738
+ tiff: "image/tiff",
1739
+ ts: "video/mp2t",
1740
+ ttf: "font/ttf",
1741
+ txt: "text/plain",
1742
+ wasm: "application/wasm",
1743
+ webm: "video/webm",
1744
+ weba: "audio/webm",
1745
+ webmanifest: "application/manifest+json",
1746
+ webp: "image/webp",
1747
+ woff: "font/woff",
1748
+ woff2: "font/woff2",
1749
+ xhtml: "application/xhtml+xml",
1750
+ xml: "application/xml",
1751
+ zip: "application/zip",
1752
+ "3gp": "video/3gpp",
1753
+ "3g2": "video/3gpp2",
1754
+ gltf: "model/gltf+json",
1755
+ glb: "model/gltf-binary"
1756
+ };
1757
+ var baseMimes = _baseMimes;
1758
+
1759
+ // node_modules/hono/dist/middleware/serve-static/path.js
1760
+ var defaultJoin = (...paths) => {
1761
+ let result = paths.filter((p) => p !== "").join("/");
1762
+ result = result.replace(/(?<=\/)\/+/g, "");
1763
+ const segments = result.split("/");
1764
+ const resolved = [];
1765
+ for (const segment of segments) {
1766
+ if (segment === ".." && resolved.length > 0 && resolved.at(-1) !== "..") {
1767
+ resolved.pop();
1768
+ } else if (segment !== ".") {
1769
+ resolved.push(segment);
1770
+ }
1771
+ }
1772
+ return resolved.join("/") || ".";
1773
+ };
1774
+
1775
+ // node_modules/hono/dist/middleware/serve-static/index.js
1776
+ var ENCODINGS = {
1777
+ br: ".br",
1778
+ zstd: ".zst",
1779
+ gzip: ".gz"
1780
+ };
1781
+ var ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS);
1782
+ var DEFAULT_DOCUMENT = "index.html";
1783
+ var serveStatic = (options) => {
1784
+ const root = options.root ?? "./";
1785
+ const optionPath = options.path;
1786
+ const join = options.join ?? defaultJoin;
1787
+ return async (c, next) => {
1788
+ if (c.finalized) {
1789
+ return next();
1790
+ }
1791
+ let filename;
1792
+ if (options.path) {
1793
+ filename = options.path;
1794
+ } else {
1795
+ try {
1796
+ filename = tryDecodeURI(c.req.path);
1797
+ if (/(?:^|[\/\\])\.\.(?:$|[\/\\])/.test(filename)) {
1798
+ throw new Error;
1799
+ }
1800
+ } catch {
1801
+ await options.onNotFound?.(c.req.path, c);
1802
+ return next();
1803
+ }
1804
+ }
1805
+ let path = join(root, !optionPath && options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename);
1806
+ if (options.isDir && await options.isDir(path)) {
1807
+ path = join(path, DEFAULT_DOCUMENT);
1808
+ }
1809
+ const getContent = options.getContent;
1810
+ let content = await getContent(path, c);
1811
+ if (content instanceof Response) {
1812
+ return c.newResponse(content.body, content);
1813
+ }
1814
+ if (content) {
1815
+ const mimeType = options.mimes && getMimeType(path, options.mimes) || getMimeType(path);
1816
+ c.header("Content-Type", mimeType || "application/octet-stream");
1817
+ if (options.precompressed && (!mimeType || COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType))) {
1818
+ const acceptEncodingSet = new Set(c.req.header("Accept-Encoding")?.split(",").map((encoding) => encoding.trim()));
1819
+ for (const encoding of ENCODINGS_ORDERED_KEYS) {
1820
+ if (!acceptEncodingSet.has(encoding)) {
1821
+ continue;
1822
+ }
1823
+ const compressedContent = await getContent(path + ENCODINGS[encoding], c);
1824
+ if (compressedContent) {
1825
+ content = compressedContent;
1826
+ c.header("Content-Encoding", encoding);
1827
+ c.header("Vary", "Accept-Encoding", { append: true });
1828
+ break;
1829
+ }
1830
+ }
1831
+ }
1832
+ await options.onFound?.(path, c);
1833
+ return c.body(content);
1834
+ }
1835
+ await options.onNotFound?.(path, c);
1836
+ await next();
1837
+ return;
1838
+ };
1839
+ };
1840
+
1841
+ // node_modules/hono/dist/adapter/bun/serve-static.js
1842
+ var serveStatic2 = (options) => {
1843
+ return async function serveStatic2(c, next) {
1844
+ const getContent = async (path) => {
1845
+ const file = Bun.file(path);
1846
+ return await file.exists() ? file : null;
1847
+ };
1848
+ const isDir = async (path) => {
1849
+ let isDir2;
1850
+ try {
1851
+ const stats = await stat(path);
1852
+ isDir2 = stats.isDirectory();
1853
+ } catch {}
1854
+ return isDir2;
1855
+ };
1856
+ return serveStatic({
1857
+ ...options,
1858
+ getContent,
1859
+ join,
1860
+ isDir
1861
+ })(c, next);
1862
+ };
1863
+ };
1864
+
1865
+ // node_modules/hono/dist/helper/ssg/middleware.js
1866
+ var X_HONO_DISABLE_SSG_HEADER_KEY = "x-hono-disable-ssg";
1867
+ var SSG_DISABLED_RESPONSE = (() => {
1868
+ try {
1869
+ return new Response("SSG is disabled", {
1870
+ status: 404,
1871
+ headers: { [X_HONO_DISABLE_SSG_HEADER_KEY]: "true" }
1872
+ });
1873
+ } catch {
1874
+ return null;
1875
+ }
1876
+ })();
1877
+ // node_modules/hono/dist/adapter/bun/ssg.js
1878
+ var { write } = Bun;
1879
+
1880
+ // node_modules/hono/dist/helper/websocket/index.js
1881
+ var WSContext = class {
1882
+ #init;
1883
+ constructor(init) {
1884
+ this.#init = init;
1885
+ this.raw = init.raw;
1886
+ this.url = init.url ? new URL(init.url) : null;
1887
+ this.protocol = init.protocol ?? null;
1888
+ }
1889
+ send(source, options) {
1890
+ this.#init.send(source, options ?? {});
1891
+ }
1892
+ raw;
1893
+ binaryType = "arraybuffer";
1894
+ get readyState() {
1895
+ return this.#init.readyState;
1896
+ }
1897
+ url;
1898
+ protocol;
1899
+ close(code, reason) {
1900
+ this.#init.close(code, reason);
1901
+ }
1902
+ };
1903
+ var defineWebSocketHelper = (handler) => {
1904
+ return (...args) => {
1905
+ if (typeof args[0] === "function") {
1906
+ const [createEvents, options] = args;
1907
+ return async function upgradeWebSocket(c, next) {
1908
+ const events = await createEvents(c);
1909
+ const result = await handler(c, events, options);
1910
+ if (result) {
1911
+ return result;
1912
+ }
1913
+ await next();
1914
+ };
1915
+ } else {
1916
+ const [c, events, options] = args;
1917
+ return (async () => {
1918
+ const upgraded = await handler(c, events, options);
1919
+ if (!upgraded) {
1920
+ throw new Error("Failed to upgrade WebSocket");
1921
+ }
1922
+ return upgraded;
1923
+ })();
1924
+ }
1925
+ };
1926
+ };
1927
+
1928
+ // node_modules/hono/dist/adapter/bun/server.js
1929
+ var getBunServer = (c) => ("server" in c.env) ? c.env.server : c.env;
1930
+
1931
+ // node_modules/hono/dist/adapter/bun/websocket.js
1932
+ var upgradeWebSocket = defineWebSocketHelper((c, events) => {
1933
+ const server = getBunServer(c);
1934
+ if (!server) {
1935
+ throw new TypeError("env has to include the 2nd argument of fetch.");
1936
+ }
1937
+ const upgradeResult = server.upgrade(c.req.raw, {
1938
+ data: {
1939
+ events,
1940
+ url: new URL(c.req.url),
1941
+ protocol: c.req.url
1942
+ }
1943
+ });
1944
+ if (upgradeResult) {
1945
+ return new Response(null);
1946
+ }
1947
+ return;
1948
+ });
1949
+
1672
1950
  // src/lib/browser-script.ts
1673
1951
  function getBrowserScript(serverUrl) {
1674
1952
  return `(function(){
@@ -1824,9 +2102,29 @@ function logsRoutes(db) {
1824
2102
  });
1825
2103
  app.get("/summary", (c) => {
1826
2104
  const { project_id, since } = c.req.query();
1827
- const summary = summarizeLogs(db, project_id || undefined, since || undefined);
2105
+ const summary = summarizeLogs(db, resolveProjectId(db, project_id) || undefined, parseTime(since) || since || undefined);
1828
2106
  return c.json(summary);
1829
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
+ });
1830
2128
  app.get("/:trace_id/context", (c) => {
1831
2129
  const rows = getLogContext(db, c.req.param("trace_id"));
1832
2130
  return c.json(rows);
@@ -2160,7 +2458,9 @@ app.route("/api/alerts", alertsRoutes(db));
2160
2458
  app.route("/api/issues", issuesRoutes(db));
2161
2459
  app.route("/api/perf", perfRoutes(db));
2162
2460
  app.get("/health", (c) => c.json(getHealth(db)));
2163
- app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok" }));
2461
+ app.get("/dashboard", (c) => c.redirect("/dashboard/"));
2462
+ app.use("/dashboard/*", serveStatic2({ root: "./dashboard/dist", rewriteRequestPath: (p) => p.replace(/^\/dashboard/, "") }));
2463
+ app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok", dashboard: `http://localhost:${PORT}/dashboard/` }));
2164
2464
  startScheduler(db);
2165
2465
  console.log(`@hasna/logs server running on http://localhost:${PORT}`);
2166
2466
  var server_default = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/logs",
3
- "version": "0.1.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",
@@ -12,6 +12,8 @@
12
12
  },
13
13
  "scripts": {
14
14
  "build": "bun build src/cli/index.ts src/mcp/index.ts src/server/index.ts --outdir dist --target bun --splitting --external playwright --external playwright-core --external electron --external chromium-bidi --external lighthouse",
15
+ "build:dashboard": "cd dashboard && bun run build",
16
+ "build:all": "bun run build:dashboard && bun run build",
15
17
  "dev": "bun run src/server/index.ts",
16
18
  "test": "bun test",
17
19
  "test:coverage": "bun test --coverage",
package/sdk/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/logs-sdk",
3
- "version": "0.0.1",
4
- "description": "Zero-dependency fetch client for @hasna/logs",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency fetch client for @hasna/logs — push logs, query, browse issues, perf snapshots",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -16,6 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "bun build src/index.ts --outdir dist --target browser"
18
18
  },
19
+ "keywords": ["logs", "monitoring", "sdk", "ai-agents"],
19
20
  "author": "Andrei Hasna <andrei@hasna.com>",
20
21
  "license": "MIT"
21
22
  }
package/sdk/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob } from "../../src/types/index.ts"
1
+ import type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob } from "./types.ts"
2
2
 
3
3
  export type { LogEntry, LogLevel, LogQuery, LogRow, LogSummary, Page, PerformanceSnapshot, Project, ScanJob }
4
4
 
@@ -0,0 +1,56 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"
2
+ export type LogSource = "sdk" | "script" | "scanner"
3
+
4
+ export interface LogEntry {
5
+ level: LogLevel
6
+ message: string
7
+ project_id?: string
8
+ page_id?: string
9
+ source?: LogSource
10
+ service?: string
11
+ trace_id?: string
12
+ session_id?: string
13
+ agent?: string
14
+ url?: string
15
+ stack_trace?: string
16
+ metadata?: Record<string, unknown>
17
+ }
18
+
19
+ export interface LogRow {
20
+ id: string; timestamp: string; project_id: string | null; page_id: string | null
21
+ level: LogLevel; source: LogSource; service: string | null; message: string
22
+ trace_id: string | null; session_id: string | null; agent: string | null
23
+ url: string | null; stack_trace: string | null; metadata: string | null
24
+ }
25
+
26
+ export interface Project {
27
+ id: string; name: string; github_repo: string | null; base_url: string | null
28
+ description: string | null; created_at: string
29
+ }
30
+
31
+ export interface Page {
32
+ id: string; project_id: string; url: string; path: string
33
+ name: string | null; last_scanned_at: string | null; created_at: string
34
+ }
35
+
36
+ export interface ScanJob {
37
+ id: string; project_id: string; page_id: string | null
38
+ schedule: string; enabled: number; last_run_at: string | null; created_at: string
39
+ }
40
+
41
+ export interface PerformanceSnapshot {
42
+ id: string; timestamp: string; project_id: string; page_id: string | null
43
+ url: string; lcp: number | null; fcp: number | null; cls: number | null
44
+ tti: number | null; ttfb: number | null; score: number | null; raw_audit: string | null
45
+ }
46
+
47
+ export interface LogQuery {
48
+ project_id?: string; page_id?: string; level?: LogLevel | LogLevel[]
49
+ service?: string; since?: string; until?: string; text?: string
50
+ trace_id?: string; limit?: number; offset?: number; fields?: string[]
51
+ }
52
+
53
+ export interface LogSummary {
54
+ project_id: string | null; service: string | null; page_id: string | null
55
+ level: LogLevel; count: number; latest: string
56
+ }
package/src/cli/index.ts CHANGED
@@ -151,6 +151,58 @@ program.command("scan")
151
151
  console.log("Scan complete.")
152
152
  })
153
153
 
154
+ // ── logs watch ────────────────────────────────────────────
155
+ program.command("watch")
156
+ .description("Stream new logs in real time with color coding")
157
+ .option("--project <id>")
158
+ .option("--level <levels>", "Comma-separated levels")
159
+ .option("--service <name>")
160
+ .action(async (opts) => {
161
+ const db = getDb()
162
+ const { searchLogs } = await import("../lib/query.ts")
163
+
164
+ const COLORS: Record<string, string> = {
165
+ debug: "\x1b[90m", info: "\x1b[36m", warn: "\x1b[33m", error: "\x1b[31m", fatal: "\x1b[35m",
166
+ }
167
+ const RESET = "\x1b[0m"
168
+ const BOLD = "\x1b[1m"
169
+
170
+ let lastTimestamp = new Date().toISOString()
171
+ let errorCount = 0
172
+ let warnCount = 0
173
+
174
+ process.stdout.write(`\x1b[2J\x1b[H`) // clear screen
175
+ console.log(`${BOLD}@hasna/logs watch${RESET} — Ctrl+C to exit\n`)
176
+
177
+ const poll = () => {
178
+ const rows = searchLogs(db, {
179
+ project_id: opts.project,
180
+ level: opts.level ? (opts.level.split(",") as LogLevel[]) : undefined,
181
+ service: opts.service,
182
+ since: lastTimestamp,
183
+ limit: 100,
184
+ }).reverse()
185
+
186
+ for (const row of rows) {
187
+ if (row.timestamp <= lastTimestamp) continue
188
+ lastTimestamp = row.timestamp
189
+ if (row.level === "error" || row.level === "fatal") errorCount++
190
+ if (row.level === "warn") warnCount++
191
+ const color = COLORS[row.level] ?? ""
192
+ const ts = row.timestamp.slice(11, 19)
193
+ const svc = (row.service ?? "-").padEnd(12)
194
+ const lvl = row.level.toUpperCase().padEnd(5)
195
+ console.log(`${color}${ts} ${BOLD}${lvl}${RESET}${color} ${svc} ${row.message}${RESET}`)
196
+ }
197
+
198
+ // Update terminal title with counts
199
+ process.stdout.write(`\x1b]2;logs: ${errorCount}E ${warnCount}W\x07`)
200
+ }
201
+
202
+ const interval = setInterval(poll, 500)
203
+ process.on("SIGINT", () => { clearInterval(interval); console.log(`\n\nErrors: ${errorCount} Warnings: ${warnCount}`); process.exit(0) })
204
+ })
205
+
154
206
  // ── logs export ───────────────────────────────────────────
155
207
  program.command("export")
156
208
  .description("Export logs to JSON or CSV")
@@ -203,11 +255,25 @@ program.command("mcp")
203
255
  .option("--gemini", "Install into Gemini")
204
256
  .action(async (opts) => {
205
257
  if (opts.claude || opts.codex || opts.gemini) {
206
- const bin = process.execPath
207
- const script = new URL(import.meta.url).pathname
258
+ const { execSync } = await import("node:child_process")
259
+ // Resolve the MCP binary path — works from both source and dist
260
+ const selfPath = process.argv[1] ?? new URL(import.meta.url).pathname
261
+ const mcpBin = selfPath.replace(/cli\/index\.(ts|js)$/, "mcp/index.$1")
262
+ const runtime = process.execPath // bun or node
263
+
208
264
  if (opts.claude) {
209
- const { execSync } = await import("node:child_process")
210
- execSync(`claude mcp add --transport stdio --scope user logs -- ${bin} ${script} mcp`, { stdio: "inherit" })
265
+ const cmd = `claude mcp add --transport stdio --scope user logs -- ${runtime} ${mcpBin}`
266
+ console.log(`Running: ${cmd}`)
267
+ execSync(cmd, { stdio: "inherit" })
268
+ console.log("✓ Installed logs-mcp into Claude Code")
269
+ }
270
+ if (opts.codex) {
271
+ const config = `[mcp_servers.logs]\ncommand = "${runtime}"\nargs = ["${mcpBin}"]`
272
+ console.log("Add to ~/.codex/config.toml:\n\n" + config)
273
+ }
274
+ if (opts.gemini) {
275
+ const config = JSON.stringify({ mcpServers: { logs: { command: runtime, args: [mcpBin] } } }, null, 2)
276
+ console.log("Add to ~/.gemini/settings.json mcpServers:\n\n" + config)
211
277
  }
212
278
  return
213
279
  }
@@ -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
+ }