@hasna/logs 0.0.1 → 0.1.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 +40 -7
- package/dist/mcp/index.js +245 -67
- package/dist/server/index.js +313 -3
- package/package.json +10 -2
- package/src/cli/index.ts +44 -0
- package/src/db/index.ts +10 -0
- package/src/db/migrations/001_alert_rules.ts +21 -0
- package/src/db/migrations/002_issues.ts +21 -0
- package/src/db/migrations/003_retention.ts +15 -0
- package/src/db/migrations/004_page_auth.ts +13 -0
- package/src/lib/alerts.test.ts +67 -0
- package/src/lib/alerts.ts +117 -0
- package/src/lib/compare.test.ts +52 -0
- package/src/lib/compare.ts +85 -0
- package/src/lib/diagnose.test.ts +55 -0
- package/src/lib/diagnose.ts +76 -0
- package/src/lib/export.test.ts +66 -0
- package/src/lib/export.ts +65 -0
- package/src/lib/health.test.ts +48 -0
- package/src/lib/health.ts +51 -0
- package/src/lib/ingest.ts +25 -2
- package/src/lib/issues.test.ts +79 -0
- package/src/lib/issues.ts +70 -0
- package/src/lib/page-auth.test.ts +54 -0
- package/src/lib/page-auth.ts +48 -0
- package/src/lib/retention.test.ts +42 -0
- package/src/lib/retention.ts +62 -0
- package/src/lib/scanner.ts +21 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/mcp/index.ts +124 -90
- package/src/server/index.ts +8 -0
- package/src/server/routes/alerts.ts +32 -0
- package/src/server/routes/issues.ts +43 -0
- package/src/server/routes/logs.ts +21 -0
- package/src/server/routes/projects.ts +25 -0
- package/src/server/routes/stream.ts +43 -0
package/dist/server/index.js
CHANGED
|
@@ -1,31 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
3
|
import {
|
|
4
|
+
deletePageAuth,
|
|
5
|
+
runRetentionForProject,
|
|
6
|
+
setPageAuth,
|
|
7
|
+
setRetentionPolicy,
|
|
4
8
|
startScheduler
|
|
5
|
-
} from "../index-
|
|
9
|
+
} from "../index-4x090f69.js";
|
|
6
10
|
import {
|
|
11
|
+
createAlertRule,
|
|
7
12
|
createPage,
|
|
8
13
|
createProject,
|
|
14
|
+
deleteAlertRule,
|
|
9
15
|
getDb,
|
|
16
|
+
getIssue,
|
|
10
17
|
getLatestSnapshot,
|
|
11
18
|
getLogContext,
|
|
12
19
|
getPerfTrend,
|
|
13
20
|
getProject,
|
|
14
21
|
ingestBatch,
|
|
15
22
|
ingestLog,
|
|
23
|
+
listAlertRules,
|
|
24
|
+
listIssues,
|
|
16
25
|
listPages,
|
|
17
26
|
listProjects,
|
|
18
27
|
searchLogs,
|
|
19
28
|
summarizeLogs,
|
|
20
29
|
tailLogs,
|
|
30
|
+
updateAlertRule,
|
|
31
|
+
updateIssueStatus,
|
|
21
32
|
updateProject
|
|
22
|
-
} from "../index-
|
|
33
|
+
} from "../index-qmsvtxax.js";
|
|
23
34
|
import {
|
|
24
35
|
createJob,
|
|
25
36
|
deleteJob,
|
|
26
37
|
listJobs,
|
|
27
38
|
updateJob
|
|
28
|
-
} from "../
|
|
39
|
+
} from "../jobs-124e878j.js";
|
|
40
|
+
import {
|
|
41
|
+
exportToCsv,
|
|
42
|
+
exportToJson
|
|
43
|
+
} from "../export-yjaw2sr3.js";
|
|
44
|
+
import {
|
|
45
|
+
getHealth
|
|
46
|
+
} from "../health-f2qrebqc.js";
|
|
47
|
+
import"../index-g8dczzvv.js";
|
|
29
48
|
|
|
30
49
|
// node_modules/hono/dist/compose.js
|
|
31
50
|
var compose = (middleware, onError, onNotFound) => {
|
|
@@ -1674,6 +1693,71 @@ window.__logs={push:push,flush:flush,config:cfg};
|
|
|
1674
1693
|
})();`;
|
|
1675
1694
|
}
|
|
1676
1695
|
|
|
1696
|
+
// src/server/routes/alerts.ts
|
|
1697
|
+
function alertsRoutes(db) {
|
|
1698
|
+
const app = new Hono2;
|
|
1699
|
+
app.post("/", async (c) => {
|
|
1700
|
+
const body = await c.req.json();
|
|
1701
|
+
if (!body.project_id || !body.name)
|
|
1702
|
+
return c.json({ error: "project_id and name required" }, 422);
|
|
1703
|
+
return c.json(createAlertRule(db, body), 201);
|
|
1704
|
+
});
|
|
1705
|
+
app.get("/", (c) => {
|
|
1706
|
+
const { project_id } = c.req.query();
|
|
1707
|
+
return c.json(listAlertRules(db, project_id || undefined));
|
|
1708
|
+
});
|
|
1709
|
+
app.put("/:id", async (c) => {
|
|
1710
|
+
const body = await c.req.json();
|
|
1711
|
+
const updated = updateAlertRule(db, c.req.param("id"), body);
|
|
1712
|
+
if (!updated)
|
|
1713
|
+
return c.json({ error: "not found" }, 404);
|
|
1714
|
+
return c.json(updated);
|
|
1715
|
+
});
|
|
1716
|
+
app.delete("/:id", (c) => {
|
|
1717
|
+
deleteAlertRule(db, c.req.param("id"));
|
|
1718
|
+
return c.json({ deleted: true });
|
|
1719
|
+
});
|
|
1720
|
+
return app;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// src/server/routes/issues.ts
|
|
1724
|
+
function issuesRoutes(db) {
|
|
1725
|
+
const app = new Hono2;
|
|
1726
|
+
app.get("/", (c) => {
|
|
1727
|
+
const { project_id, status, limit } = c.req.query();
|
|
1728
|
+
return c.json(listIssues(db, project_id || undefined, status || undefined, limit ? Number(limit) : 50));
|
|
1729
|
+
});
|
|
1730
|
+
app.get("/:id", (c) => {
|
|
1731
|
+
const issue = getIssue(db, c.req.param("id"));
|
|
1732
|
+
if (!issue)
|
|
1733
|
+
return c.json({ error: "not found" }, 404);
|
|
1734
|
+
return c.json(issue);
|
|
1735
|
+
});
|
|
1736
|
+
app.get("/:id/logs", (c) => {
|
|
1737
|
+
const issue = getIssue(db, c.req.param("id"));
|
|
1738
|
+
if (!issue)
|
|
1739
|
+
return c.json({ error: "not found" }, 404);
|
|
1740
|
+
const rows = searchLogs(db, {
|
|
1741
|
+
project_id: issue.project_id ?? undefined,
|
|
1742
|
+
level: issue.level,
|
|
1743
|
+
service: issue.service ?? undefined,
|
|
1744
|
+
text: issue.message_template.slice(0, 50),
|
|
1745
|
+
limit: 50
|
|
1746
|
+
});
|
|
1747
|
+
return c.json(rows);
|
|
1748
|
+
});
|
|
1749
|
+
app.put("/:id", async (c) => {
|
|
1750
|
+
const { status } = await c.req.json();
|
|
1751
|
+
if (!["open", "resolved", "ignored"].includes(status))
|
|
1752
|
+
return c.json({ error: "invalid status" }, 422);
|
|
1753
|
+
const updated = updateIssueStatus(db, c.req.param("id"), status);
|
|
1754
|
+
if (!updated)
|
|
1755
|
+
return c.json({ error: "not found" }, 404);
|
|
1756
|
+
return c.json(updated);
|
|
1757
|
+
});
|
|
1758
|
+
return app;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1677
1761
|
// src/server/routes/jobs.ts
|
|
1678
1762
|
function jobsRoutes(db) {
|
|
1679
1763
|
const app = new Hono2;
|
|
@@ -1747,6 +1831,23 @@ function logsRoutes(db) {
|
|
|
1747
1831
|
const rows = getLogContext(db, c.req.param("trace_id"));
|
|
1748
1832
|
return c.json(rows);
|
|
1749
1833
|
});
|
|
1834
|
+
app.get("/export", (c) => {
|
|
1835
|
+
const { project_id, since, until, level, service, format, limit } = c.req.query();
|
|
1836
|
+
const opts = { project_id: project_id || undefined, since: since || undefined, until: until || undefined, level: level || undefined, service: service || undefined, limit: limit ? Number(limit) : undefined };
|
|
1837
|
+
if (format === "csv") {
|
|
1838
|
+
c.header("Content-Type", "text/csv");
|
|
1839
|
+
c.header("Content-Disposition", "attachment; filename=logs.csv");
|
|
1840
|
+
const chunks2 = [];
|
|
1841
|
+
exportToCsv(db, opts, (s) => chunks2.push(s));
|
|
1842
|
+
return c.text(chunks2.join(""));
|
|
1843
|
+
}
|
|
1844
|
+
c.header("Content-Type", "application/json");
|
|
1845
|
+
c.header("Content-Disposition", "attachment; filename=logs.json");
|
|
1846
|
+
const chunks = [];
|
|
1847
|
+
exportToJson(db, opts, (s) => chunks.push(s));
|
|
1848
|
+
return c.text(chunks.join(`
|
|
1849
|
+
`));
|
|
1850
|
+
});
|
|
1750
1851
|
return app;
|
|
1751
1852
|
}
|
|
1752
1853
|
|
|
@@ -1823,6 +1924,26 @@ function projectsRoutes(db) {
|
|
|
1823
1924
|
return c.json(page, 201);
|
|
1824
1925
|
});
|
|
1825
1926
|
app.get("/:id/pages", (c) => c.json(listPages(db, c.req.param("id"))));
|
|
1927
|
+
app.put("/:id/retention", async (c) => {
|
|
1928
|
+
const body = await c.req.json();
|
|
1929
|
+
setRetentionPolicy(db, c.req.param("id"), body);
|
|
1930
|
+
return c.json({ updated: true });
|
|
1931
|
+
});
|
|
1932
|
+
app.post("/:id/retention/run", (c) => {
|
|
1933
|
+
const result = runRetentionForProject(db, c.req.param("id"));
|
|
1934
|
+
return c.json(result);
|
|
1935
|
+
});
|
|
1936
|
+
app.post("/:id/pages/:page_id/auth", async (c) => {
|
|
1937
|
+
const { type, credentials } = await c.req.json();
|
|
1938
|
+
if (!type || !credentials)
|
|
1939
|
+
return c.json({ error: "type and credentials required" }, 422);
|
|
1940
|
+
const result = setPageAuth(db, c.req.param("page_id"), type, credentials);
|
|
1941
|
+
return c.json({ id: result.id, type: result.type, created_at: result.created_at }, 201);
|
|
1942
|
+
});
|
|
1943
|
+
app.delete("/:id/pages/:page_id/auth", (c) => {
|
|
1944
|
+
deletePageAuth(db, c.req.param("page_id"));
|
|
1945
|
+
return c.json({ deleted: true });
|
|
1946
|
+
});
|
|
1826
1947
|
app.post("/:id/sync-repo", async (c) => {
|
|
1827
1948
|
const project = getProject(db, c.req.param("id"));
|
|
1828
1949
|
if (!project)
|
|
@@ -1835,6 +1956,191 @@ function projectsRoutes(db) {
|
|
|
1835
1956
|
return app;
|
|
1836
1957
|
}
|
|
1837
1958
|
|
|
1959
|
+
// node_modules/hono/dist/utils/stream.js
|
|
1960
|
+
var StreamingApi = class {
|
|
1961
|
+
writer;
|
|
1962
|
+
encoder;
|
|
1963
|
+
writable;
|
|
1964
|
+
abortSubscribers = [];
|
|
1965
|
+
responseReadable;
|
|
1966
|
+
aborted = false;
|
|
1967
|
+
closed = false;
|
|
1968
|
+
constructor(writable, _readable) {
|
|
1969
|
+
this.writable = writable;
|
|
1970
|
+
this.writer = writable.getWriter();
|
|
1971
|
+
this.encoder = new TextEncoder;
|
|
1972
|
+
const reader = _readable.getReader();
|
|
1973
|
+
this.abortSubscribers.push(async () => {
|
|
1974
|
+
await reader.cancel();
|
|
1975
|
+
});
|
|
1976
|
+
this.responseReadable = new ReadableStream({
|
|
1977
|
+
async pull(controller) {
|
|
1978
|
+
const { done, value } = await reader.read();
|
|
1979
|
+
done ? controller.close() : controller.enqueue(value);
|
|
1980
|
+
},
|
|
1981
|
+
cancel: () => {
|
|
1982
|
+
this.abort();
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
async write(input) {
|
|
1987
|
+
try {
|
|
1988
|
+
if (typeof input === "string") {
|
|
1989
|
+
input = this.encoder.encode(input);
|
|
1990
|
+
}
|
|
1991
|
+
await this.writer.write(input);
|
|
1992
|
+
} catch {}
|
|
1993
|
+
return this;
|
|
1994
|
+
}
|
|
1995
|
+
async writeln(input) {
|
|
1996
|
+
await this.write(input + `
|
|
1997
|
+
`);
|
|
1998
|
+
return this;
|
|
1999
|
+
}
|
|
2000
|
+
sleep(ms) {
|
|
2001
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
2002
|
+
}
|
|
2003
|
+
async close() {
|
|
2004
|
+
try {
|
|
2005
|
+
await this.writer.close();
|
|
2006
|
+
} catch {}
|
|
2007
|
+
this.closed = true;
|
|
2008
|
+
}
|
|
2009
|
+
async pipe(body) {
|
|
2010
|
+
this.writer.releaseLock();
|
|
2011
|
+
await body.pipeTo(this.writable, { preventClose: true });
|
|
2012
|
+
this.writer = this.writable.getWriter();
|
|
2013
|
+
}
|
|
2014
|
+
onAbort(listener) {
|
|
2015
|
+
this.abortSubscribers.push(listener);
|
|
2016
|
+
}
|
|
2017
|
+
abort() {
|
|
2018
|
+
if (!this.aborted) {
|
|
2019
|
+
this.aborted = true;
|
|
2020
|
+
this.abortSubscribers.forEach((subscriber) => subscriber());
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
// node_modules/hono/dist/helper/streaming/utils.js
|
|
2026
|
+
var isOldBunVersion = () => {
|
|
2027
|
+
const version = typeof Bun !== "undefined" ? Bun.version : undefined;
|
|
2028
|
+
if (version === undefined) {
|
|
2029
|
+
return false;
|
|
2030
|
+
}
|
|
2031
|
+
const result = version.startsWith("1.1") || version.startsWith("1.0") || version.startsWith("0.");
|
|
2032
|
+
isOldBunVersion = () => result;
|
|
2033
|
+
return result;
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
// node_modules/hono/dist/helper/streaming/sse.js
|
|
2037
|
+
var SSEStreamingApi = class extends StreamingApi {
|
|
2038
|
+
constructor(writable, readable) {
|
|
2039
|
+
super(writable, readable);
|
|
2040
|
+
}
|
|
2041
|
+
async writeSSE(message) {
|
|
2042
|
+
const data = await resolveCallback(message.data, HtmlEscapedCallbackPhase.Stringify, false, {});
|
|
2043
|
+
const dataLines = data.split(/\r\n|\r|\n/).map((line) => {
|
|
2044
|
+
return `data: ${line}`;
|
|
2045
|
+
}).join(`
|
|
2046
|
+
`);
|
|
2047
|
+
for (const key of ["event", "id", "retry"]) {
|
|
2048
|
+
if (message[key] && /[\r\n]/.test(message[key])) {
|
|
2049
|
+
throw new Error(`${key} must not contain "\\r" or "\\n"`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
const sseData = [
|
|
2053
|
+
message.event && `event: ${message.event}`,
|
|
2054
|
+
dataLines,
|
|
2055
|
+
message.id && `id: ${message.id}`,
|
|
2056
|
+
message.retry && `retry: ${message.retry}`
|
|
2057
|
+
].filter(Boolean).join(`
|
|
2058
|
+
`) + `
|
|
2059
|
+
|
|
2060
|
+
`;
|
|
2061
|
+
await this.write(sseData);
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
var run = async (stream, cb, onError) => {
|
|
2065
|
+
try {
|
|
2066
|
+
await cb(stream);
|
|
2067
|
+
} catch (e) {
|
|
2068
|
+
if (e instanceof Error && onError) {
|
|
2069
|
+
await onError(e, stream);
|
|
2070
|
+
await stream.writeSSE({
|
|
2071
|
+
event: "error",
|
|
2072
|
+
data: e.message
|
|
2073
|
+
});
|
|
2074
|
+
} else {
|
|
2075
|
+
console.error(e);
|
|
2076
|
+
}
|
|
2077
|
+
} finally {
|
|
2078
|
+
stream.close();
|
|
2079
|
+
}
|
|
2080
|
+
};
|
|
2081
|
+
var contextStash = /* @__PURE__ */ new WeakMap;
|
|
2082
|
+
var streamSSE = (c, cb, onError) => {
|
|
2083
|
+
const { readable, writable } = new TransformStream;
|
|
2084
|
+
const stream = new SSEStreamingApi(writable, readable);
|
|
2085
|
+
if (isOldBunVersion()) {
|
|
2086
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
2087
|
+
if (!stream.closed) {
|
|
2088
|
+
stream.abort();
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
contextStash.set(stream.responseReadable, c);
|
|
2093
|
+
c.header("Transfer-Encoding", "chunked");
|
|
2094
|
+
c.header("Content-Type", "text/event-stream");
|
|
2095
|
+
c.header("Cache-Control", "no-cache");
|
|
2096
|
+
c.header("Connection", "keep-alive");
|
|
2097
|
+
run(stream, cb, onError);
|
|
2098
|
+
return c.newResponse(stream.responseReadable);
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
// src/server/routes/stream.ts
|
|
2102
|
+
function streamRoutes(db) {
|
|
2103
|
+
const app = new Hono2;
|
|
2104
|
+
app.get("/", (c) => {
|
|
2105
|
+
const { project_id, level, service } = c.req.query();
|
|
2106
|
+
return streamSSE(c, async (stream2) => {
|
|
2107
|
+
let lastId = null;
|
|
2108
|
+
const latest = db.prepare("SELECT id FROM logs ORDER BY timestamp DESC LIMIT 1").get();
|
|
2109
|
+
lastId = latest?.id ?? null;
|
|
2110
|
+
while (true) {
|
|
2111
|
+
const conditions = [];
|
|
2112
|
+
const params = {};
|
|
2113
|
+
if (lastId) {
|
|
2114
|
+
conditions.push("rowid > (SELECT rowid FROM logs WHERE id = $lastId)");
|
|
2115
|
+
params.$lastId = lastId;
|
|
2116
|
+
}
|
|
2117
|
+
if (project_id) {
|
|
2118
|
+
conditions.push("project_id = $project_id");
|
|
2119
|
+
params.$project_id = project_id;
|
|
2120
|
+
}
|
|
2121
|
+
if (level) {
|
|
2122
|
+
conditions.push("level IN (" + level.split(",").map((l, i) => `$l${i}`).join(",") + ")");
|
|
2123
|
+
level.split(",").forEach((l, i) => {
|
|
2124
|
+
params[`$l${i}`] = l;
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
if (service) {
|
|
2128
|
+
conditions.push("service = $service");
|
|
2129
|
+
params.$service = service;
|
|
2130
|
+
}
|
|
2131
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2132
|
+
const rows = db.prepare(`SELECT * FROM logs ${where} ORDER BY timestamp ASC LIMIT 50`).all(params);
|
|
2133
|
+
for (const row of rows) {
|
|
2134
|
+
await stream2.writeSSE({ data: JSON.stringify(row), id: row.id, event: row.level });
|
|
2135
|
+
lastId = row.id;
|
|
2136
|
+
}
|
|
2137
|
+
await stream2.sleep(500);
|
|
2138
|
+
}
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
return app;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
1838
2144
|
// src/server/index.ts
|
|
1839
2145
|
var PORT = Number(process.env.LOGS_PORT ?? 3460);
|
|
1840
2146
|
var db = getDb();
|
|
@@ -1847,9 +2153,13 @@ app.get("/script.js", (c) => {
|
|
|
1847
2153
|
return c.text(getBrowserScript(host));
|
|
1848
2154
|
});
|
|
1849
2155
|
app.route("/api/logs", logsRoutes(db));
|
|
2156
|
+
app.route("/api/logs/stream", streamRoutes(db));
|
|
1850
2157
|
app.route("/api/projects", projectsRoutes(db));
|
|
1851
2158
|
app.route("/api/jobs", jobsRoutes(db));
|
|
2159
|
+
app.route("/api/alerts", alertsRoutes(db));
|
|
2160
|
+
app.route("/api/issues", issuesRoutes(db));
|
|
1852
2161
|
app.route("/api/perf", perfRoutes(db));
|
|
2162
|
+
app.get("/health", (c) => c.json(getHealth(db)));
|
|
1853
2163
|
app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok" }));
|
|
1854
2164
|
startScheduler(db);
|
|
1855
2165
|
console.log(`@hasna/logs server running on http://localhost:${PORT}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/logs",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.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",
|
|
@@ -21,7 +21,15 @@
|
|
|
21
21
|
"access": "restricted",
|
|
22
22
|
"registry": "https://registry.npmjs.org/"
|
|
23
23
|
},
|
|
24
|
-
"keywords": [
|
|
24
|
+
"keywords": [
|
|
25
|
+
"logs",
|
|
26
|
+
"monitoring",
|
|
27
|
+
"mcp",
|
|
28
|
+
"ai-agents",
|
|
29
|
+
"sentry",
|
|
30
|
+
"performance",
|
|
31
|
+
"lighthouse"
|
|
32
|
+
],
|
|
25
33
|
"author": "Andrei Hasna <andrei@hasna.com>",
|
|
26
34
|
"license": "MIT",
|
|
27
35
|
"dependencies": {
|
package/src/cli/index.ts
CHANGED
|
@@ -151,6 +151,50 @@ program.command("scan")
|
|
|
151
151
|
console.log("Scan complete.")
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
// ── logs export ───────────────────────────────────────────
|
|
155
|
+
program.command("export")
|
|
156
|
+
.description("Export logs to JSON or CSV")
|
|
157
|
+
.option("--project <id>")
|
|
158
|
+
.option("--since <time>", "Relative time or ISO")
|
|
159
|
+
.option("--level <level>")
|
|
160
|
+
.option("--service <name>")
|
|
161
|
+
.option("--format <fmt>", "json or csv", "json")
|
|
162
|
+
.option("--output <file>", "Output file (default: stdout)")
|
|
163
|
+
.option("--limit <n>", "Max rows", "100000")
|
|
164
|
+
.action(async (opts) => {
|
|
165
|
+
const { exportToCsv, exportToJson } = await import("../lib/export.ts")
|
|
166
|
+
const { createWriteStream } = await import("node:fs")
|
|
167
|
+
const db = getDb()
|
|
168
|
+
const options = {
|
|
169
|
+
project_id: opts.project,
|
|
170
|
+
since: parseRelativeTime(opts.since),
|
|
171
|
+
level: opts.level,
|
|
172
|
+
service: opts.service,
|
|
173
|
+
limit: Number(opts.limit),
|
|
174
|
+
}
|
|
175
|
+
let count = 0
|
|
176
|
+
if (opts.output) {
|
|
177
|
+
const stream = createWriteStream(opts.output)
|
|
178
|
+
const write = (s: string) => stream.write(s)
|
|
179
|
+
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
180
|
+
stream.end()
|
|
181
|
+
console.error(`Exported ${count} log(s) to ${opts.output}`)
|
|
182
|
+
} else {
|
|
183
|
+
const write = (s: string) => process.stdout.write(s)
|
|
184
|
+
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
185
|
+
process.stderr.write(`\nExported ${count} log(s)\n`)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ── logs health ───────────────────────────────────────────
|
|
190
|
+
program.command("health")
|
|
191
|
+
.description("Show server health and DB stats")
|
|
192
|
+
.action(async () => {
|
|
193
|
+
const { getHealth } = await import("../lib/health.ts")
|
|
194
|
+
const h = getHealth(getDb())
|
|
195
|
+
console.log(JSON.stringify(h, null, 2))
|
|
196
|
+
})
|
|
197
|
+
|
|
154
198
|
// ── logs mcp / logs serve ─────────────────────────────────
|
|
155
199
|
program.command("mcp")
|
|
156
200
|
.description("Start the MCP server")
|
package/src/db/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { existsSync, mkdirSync } from "node:fs"
|
|
4
|
+
import { migrateAlertRules } from "./migrations/001_alert_rules.ts"
|
|
5
|
+
import { migrateIssues } from "./migrations/002_issues.ts"
|
|
6
|
+
import { migrateRetention } from "./migrations/003_retention.ts"
|
|
7
|
+
import { migratePageAuth } from "./migrations/004_page_auth.ts"
|
|
4
8
|
|
|
5
9
|
const DATA_DIR = process.env.LOGS_DATA_DIR ?? join(process.env.HOME ?? "~", ".logs")
|
|
6
10
|
const DB_PATH = process.env.LOGS_DB_PATH ?? join(DATA_DIR, "logs.db")
|
|
@@ -150,4 +154,10 @@ function migrate(db: Database): void {
|
|
|
150
154
|
|
|
151
155
|
db.run(`CREATE INDEX IF NOT EXISTS idx_perf_project_ts ON performance_snapshots(project_id, timestamp DESC)`)
|
|
152
156
|
db.run(`CREATE INDEX IF NOT EXISTS idx_perf_page ON performance_snapshots(page_id)`)
|
|
157
|
+
|
|
158
|
+
// QoL migrations
|
|
159
|
+
migrateAlertRules(db)
|
|
160
|
+
migrateIssues(db)
|
|
161
|
+
migrateRetention(db)
|
|
162
|
+
migratePageAuth(db)
|
|
153
163
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export function migrateAlertRules(db: Database): void {
|
|
4
|
+
db.run(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
6
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
7
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
service TEXT,
|
|
10
|
+
level TEXT NOT NULL DEFAULT 'error' CHECK(level IN ('debug','info','warn','error','fatal')),
|
|
11
|
+
threshold_count INTEGER NOT NULL DEFAULT 10,
|
|
12
|
+
window_seconds INTEGER NOT NULL DEFAULT 60,
|
|
13
|
+
action TEXT NOT NULL DEFAULT 'webhook' CHECK(action IN ('webhook','log')),
|
|
14
|
+
webhook_url TEXT,
|
|
15
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
16
|
+
last_fired_at TEXT,
|
|
17
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
18
|
+
)
|
|
19
|
+
`)
|
|
20
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_alert_rules_project ON alert_rules(project_id)`)
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export function migrateIssues(db: Database): void {
|
|
4
|
+
db.run(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS issues (
|
|
6
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
7
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
fingerprint TEXT NOT NULL,
|
|
9
|
+
level TEXT NOT NULL,
|
|
10
|
+
service TEXT,
|
|
11
|
+
message_template TEXT NOT NULL,
|
|
12
|
+
first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
13
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
14
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
15
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','resolved','ignored')),
|
|
16
|
+
UNIQUE(project_id, fingerprint)
|
|
17
|
+
)
|
|
18
|
+
`)
|
|
19
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, status)`)
|
|
20
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_issues_fingerprint ON issues(fingerprint)`)
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
const RETENTION_COLUMNS = [
|
|
4
|
+
"max_rows INTEGER NOT NULL DEFAULT 100000",
|
|
5
|
+
"debug_ttl_hours INTEGER NOT NULL DEFAULT 24",
|
|
6
|
+
"info_ttl_hours INTEGER NOT NULL DEFAULT 168",
|
|
7
|
+
"warn_ttl_hours INTEGER NOT NULL DEFAULT 720",
|
|
8
|
+
"error_ttl_hours INTEGER NOT NULL DEFAULT 2160",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
export function migrateRetention(db: Database): void {
|
|
12
|
+
for (const col of RETENTION_COLUMNS) {
|
|
13
|
+
try { db.run(`ALTER TABLE projects ADD COLUMN ${col}`) } catch { /* already exists */ }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export function migratePageAuth(db: Database): void {
|
|
4
|
+
db.run(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS page_auth (
|
|
6
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
7
|
+
page_id TEXT NOT NULL UNIQUE REFERENCES pages(id) ON DELETE CASCADE,
|
|
8
|
+
type TEXT NOT NULL CHECK(type IN ('cookie','bearer','basic')),
|
|
9
|
+
credentials TEXT NOT NULL,
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
11
|
+
)
|
|
12
|
+
`)
|
|
13
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test"
|
|
2
|
+
import { createTestDb } from "../db/index.ts"
|
|
3
|
+
import { createAlertRule, deleteAlertRule, evaluateAlerts, listAlertRules, updateAlertRule } from "./alerts.ts"
|
|
4
|
+
import { ingestBatch } from "./ingest.ts"
|
|
5
|
+
|
|
6
|
+
function seedProject(db: ReturnType<typeof createTestDb>) {
|
|
7
|
+
return db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("alert rules CRUD", () => {
|
|
11
|
+
it("creates an alert rule", () => {
|
|
12
|
+
const db = createTestDb()
|
|
13
|
+
const p = seedProject(db)
|
|
14
|
+
const rule = createAlertRule(db, { project_id: p.id, name: "High errors", level: "error", threshold_count: 5, window_seconds: 60 })
|
|
15
|
+
expect(rule.id).toBeTruthy()
|
|
16
|
+
expect(rule.name).toBe("High errors")
|
|
17
|
+
expect(rule.threshold_count).toBe(5)
|
|
18
|
+
expect(rule.enabled).toBe(1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("lists rules for a project", () => {
|
|
22
|
+
const db = createTestDb()
|
|
23
|
+
const p = seedProject(db)
|
|
24
|
+
createAlertRule(db, { project_id: p.id, name: "r1" })
|
|
25
|
+
createAlertRule(db, { project_id: p.id, name: "r2" })
|
|
26
|
+
expect(listAlertRules(db, p.id)).toHaveLength(2)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("updates a rule", () => {
|
|
30
|
+
const db = createTestDb()
|
|
31
|
+
const p = seedProject(db)
|
|
32
|
+
const rule = createAlertRule(db, { project_id: p.id, name: "r1" })
|
|
33
|
+
const updated = updateAlertRule(db, rule.id, { enabled: 0, threshold_count: 99 })
|
|
34
|
+
expect(updated?.enabled).toBe(0)
|
|
35
|
+
expect(updated?.threshold_count).toBe(99)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("deletes a rule", () => {
|
|
39
|
+
const db = createTestDb()
|
|
40
|
+
const p = seedProject(db)
|
|
41
|
+
const rule = createAlertRule(db, { project_id: p.id, name: "r1" })
|
|
42
|
+
deleteAlertRule(db, rule.id)
|
|
43
|
+
expect(listAlertRules(db, p.id)).toHaveLength(0)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("alert evaluation", () => {
|
|
48
|
+
it("does not fire when under threshold", async () => {
|
|
49
|
+
const db = createTestDb()
|
|
50
|
+
const p = seedProject(db)
|
|
51
|
+
createAlertRule(db, { project_id: p.id, name: "r", level: "error", threshold_count: 10, window_seconds: 60, action: "log" })
|
|
52
|
+
ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "error" as const, message: "e", project_id: p.id })))
|
|
53
|
+
// No throw = passes
|
|
54
|
+
await expect(evaluateAlerts(db, p.id, null, "error")).resolves.toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("fires when threshold exceeded (log action)", async () => {
|
|
58
|
+
const db = createTestDb()
|
|
59
|
+
const p = seedProject(db)
|
|
60
|
+
createAlertRule(db, { project_id: p.id, name: "r", level: "error", threshold_count: 3, window_seconds: 3600, action: "log" })
|
|
61
|
+
ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "error" as const, message: "e", project_id: p.id })))
|
|
62
|
+
await expect(evaluateAlerts(db, p.id, null, "error")).resolves.toBeUndefined()
|
|
63
|
+
// Verify last_fired_at was set
|
|
64
|
+
const rule = db.prepare("SELECT last_fired_at FROM alert_rules WHERE project_id = ?").get(p.id) as { last_fired_at: string | null }
|
|
65
|
+
expect(rule.last_fired_at).toBeTruthy()
|
|
66
|
+
})
|
|
67
|
+
})
|