@hasna/logs 0.3.9 → 0.3.11
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 +45 -2
- package/dist/index-2sbhn1ye.js +1241 -0
- package/dist/index-t97ttm0a.js +543 -0
- package/dist/mcp/index.js +30 -5
- package/dist/server/index.js +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +57 -0
- package/src/lib/ingest.ts +5 -1
- package/src/mcp/index.ts +30 -3
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
parseTime
|
|
4
|
+
} from "./index-997bkzr2.js";
|
|
5
|
+
|
|
6
|
+
// src/db/index.ts
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { existsSync, mkdirSync } from "fs";
|
|
10
|
+
|
|
11
|
+
// src/db/migrations/001_alert_rules.ts
|
|
12
|
+
function migrateAlertRules(db) {
|
|
13
|
+
db.run(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
15
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
16
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
service TEXT,
|
|
19
|
+
level TEXT NOT NULL DEFAULT 'error' CHECK(level IN ('debug','info','warn','error','fatal')),
|
|
20
|
+
threshold_count INTEGER NOT NULL DEFAULT 10,
|
|
21
|
+
window_seconds INTEGER NOT NULL DEFAULT 60,
|
|
22
|
+
action TEXT NOT NULL DEFAULT 'webhook' CHECK(action IN ('webhook','log')),
|
|
23
|
+
webhook_url TEXT,
|
|
24
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
last_fired_at TEXT,
|
|
26
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
27
|
+
)
|
|
28
|
+
`);
|
|
29
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_alert_rules_project ON alert_rules(project_id)`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/db/migrations/002_issues.ts
|
|
33
|
+
function migrateIssues(db) {
|
|
34
|
+
db.run(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS issues (
|
|
36
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
37
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
38
|
+
fingerprint TEXT NOT NULL,
|
|
39
|
+
level TEXT NOT NULL,
|
|
40
|
+
service TEXT,
|
|
41
|
+
message_template TEXT NOT NULL,
|
|
42
|
+
first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
43
|
+
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
44
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
45
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','resolved','ignored')),
|
|
46
|
+
UNIQUE(project_id, fingerprint)
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, status)`);
|
|
50
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_issues_fingerprint ON issues(fingerprint)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/db/migrations/003_retention.ts
|
|
54
|
+
var RETENTION_COLUMNS = [
|
|
55
|
+
"max_rows INTEGER NOT NULL DEFAULT 100000",
|
|
56
|
+
"debug_ttl_hours INTEGER NOT NULL DEFAULT 24",
|
|
57
|
+
"info_ttl_hours INTEGER NOT NULL DEFAULT 168",
|
|
58
|
+
"warn_ttl_hours INTEGER NOT NULL DEFAULT 720",
|
|
59
|
+
"error_ttl_hours INTEGER NOT NULL DEFAULT 2160"
|
|
60
|
+
];
|
|
61
|
+
function migrateRetention(db) {
|
|
62
|
+
for (const col of RETENTION_COLUMNS) {
|
|
63
|
+
try {
|
|
64
|
+
db.run(`ALTER TABLE projects ADD COLUMN ${col}`);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/db/migrations/004_page_auth.ts
|
|
70
|
+
function migratePageAuth(db) {
|
|
71
|
+
db.run(`
|
|
72
|
+
CREATE TABLE IF NOT EXISTS page_auth (
|
|
73
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
74
|
+
page_id TEXT NOT NULL UNIQUE REFERENCES pages(id) ON DELETE CASCADE,
|
|
75
|
+
type TEXT NOT NULL CHECK(type IN ('cookie','bearer','basic')),
|
|
76
|
+
credentials TEXT NOT NULL,
|
|
77
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
78
|
+
)
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/db/index.ts
|
|
83
|
+
var DATA_DIR = process.env.LOGS_DATA_DIR ?? join(process.env.HOME ?? "~", ".logs");
|
|
84
|
+
var DB_PATH = process.env.LOGS_DB_PATH ?? join(DATA_DIR, "logs.db");
|
|
85
|
+
var _db = null;
|
|
86
|
+
function getDb() {
|
|
87
|
+
if (_db)
|
|
88
|
+
return _db;
|
|
89
|
+
if (!existsSync(DATA_DIR))
|
|
90
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
91
|
+
_db = new Database(DB_PATH);
|
|
92
|
+
_db.run("PRAGMA journal_mode=WAL");
|
|
93
|
+
_db.run("PRAGMA foreign_keys=ON");
|
|
94
|
+
migrate(_db);
|
|
95
|
+
return _db;
|
|
96
|
+
}
|
|
97
|
+
function migrate(db) {
|
|
98
|
+
db.run(`
|
|
99
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
100
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
101
|
+
name TEXT NOT NULL UNIQUE,
|
|
102
|
+
github_repo TEXT,
|
|
103
|
+
base_url TEXT,
|
|
104
|
+
description TEXT,
|
|
105
|
+
github_description TEXT,
|
|
106
|
+
github_branch TEXT,
|
|
107
|
+
github_sha TEXT,
|
|
108
|
+
last_synced_at TEXT,
|
|
109
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
110
|
+
)
|
|
111
|
+
`);
|
|
112
|
+
db.run(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
114
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
115
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
116
|
+
url TEXT NOT NULL,
|
|
117
|
+
path TEXT NOT NULL DEFAULT '/',
|
|
118
|
+
name TEXT,
|
|
119
|
+
last_scanned_at TEXT,
|
|
120
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
121
|
+
UNIQUE(project_id, url)
|
|
122
|
+
)
|
|
123
|
+
`);
|
|
124
|
+
db.run(`
|
|
125
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
126
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
127
|
+
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
128
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
129
|
+
page_id TEXT REFERENCES pages(id) ON DELETE SET NULL,
|
|
130
|
+
level TEXT NOT NULL CHECK(level IN ('debug','info','warn','error','fatal')),
|
|
131
|
+
source TEXT NOT NULL DEFAULT 'sdk' CHECK(source IN ('sdk','script','scanner')),
|
|
132
|
+
service TEXT,
|
|
133
|
+
message TEXT NOT NULL,
|
|
134
|
+
trace_id TEXT,
|
|
135
|
+
session_id TEXT,
|
|
136
|
+
agent TEXT,
|
|
137
|
+
url TEXT,
|
|
138
|
+
stack_trace TEXT,
|
|
139
|
+
metadata TEXT
|
|
140
|
+
)
|
|
141
|
+
`);
|
|
142
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_logs_project_level_ts ON logs(project_id, level, timestamp DESC)`);
|
|
143
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_logs_trace ON logs(trace_id)`);
|
|
144
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_logs_service ON logs(service)`);
|
|
145
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_logs_page ON logs(page_id)`);
|
|
146
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC)`);
|
|
147
|
+
db.run(`
|
|
148
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS logs_fts USING fts5(
|
|
149
|
+
message, service, stack_trace,
|
|
150
|
+
content=logs, content_rowid=rowid
|
|
151
|
+
)
|
|
152
|
+
`);
|
|
153
|
+
db.run(`
|
|
154
|
+
CREATE TRIGGER IF NOT EXISTS logs_fts_insert AFTER INSERT ON logs BEGIN
|
|
155
|
+
INSERT INTO logs_fts(rowid, message, service, stack_trace)
|
|
156
|
+
VALUES (new.rowid, new.message, new.service, new.stack_trace);
|
|
157
|
+
END
|
|
158
|
+
`);
|
|
159
|
+
db.run(`
|
|
160
|
+
CREATE TRIGGER IF NOT EXISTS logs_fts_delete AFTER DELETE ON logs BEGIN
|
|
161
|
+
INSERT INTO logs_fts(logs_fts, rowid, message, service, stack_trace)
|
|
162
|
+
VALUES ('delete', old.rowid, old.message, old.service, old.stack_trace);
|
|
163
|
+
END
|
|
164
|
+
`);
|
|
165
|
+
db.run(`
|
|
166
|
+
CREATE TABLE IF NOT EXISTS scan_jobs (
|
|
167
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
168
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
169
|
+
page_id TEXT REFERENCES pages(id) ON DELETE SET NULL,
|
|
170
|
+
schedule TEXT NOT NULL DEFAULT '*/30 * * * *',
|
|
171
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
172
|
+
last_run_at TEXT,
|
|
173
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
174
|
+
)
|
|
175
|
+
`);
|
|
176
|
+
db.run(`
|
|
177
|
+
CREATE TABLE IF NOT EXISTS scan_runs (
|
|
178
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
179
|
+
job_id TEXT NOT NULL REFERENCES scan_jobs(id) ON DELETE CASCADE,
|
|
180
|
+
page_id TEXT REFERENCES pages(id) ON DELETE SET NULL,
|
|
181
|
+
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
182
|
+
finished_at TEXT,
|
|
183
|
+
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','completed','failed')),
|
|
184
|
+
logs_collected INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
errors_found INTEGER NOT NULL DEFAULT 0,
|
|
186
|
+
perf_score REAL
|
|
187
|
+
)
|
|
188
|
+
`);
|
|
189
|
+
db.run(`
|
|
190
|
+
CREATE TABLE IF NOT EXISTS performance_snapshots (
|
|
191
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
|
192
|
+
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
193
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
194
|
+
page_id TEXT REFERENCES pages(id) ON DELETE SET NULL,
|
|
195
|
+
url TEXT NOT NULL,
|
|
196
|
+
lcp REAL,
|
|
197
|
+
fcp REAL,
|
|
198
|
+
cls REAL,
|
|
199
|
+
tti REAL,
|
|
200
|
+
ttfb REAL,
|
|
201
|
+
score REAL,
|
|
202
|
+
raw_audit TEXT
|
|
203
|
+
)
|
|
204
|
+
`);
|
|
205
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_perf_project_ts ON performance_snapshots(project_id, timestamp DESC)`);
|
|
206
|
+
db.run(`CREATE INDEX IF NOT EXISTS idx_perf_page ON performance_snapshots(page_id)`);
|
|
207
|
+
migrateAlertRules(db);
|
|
208
|
+
migrateIssues(db);
|
|
209
|
+
migrateRetention(db);
|
|
210
|
+
migratePageAuth(db);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/lib/issues.ts
|
|
214
|
+
import { createHash } from "crypto";
|
|
215
|
+
function computeFingerprint(level, service, message, stackTrace) {
|
|
216
|
+
const normalized = message.replace(/[0-9a-f]{8,}/gi, "<id>").replace(/\d+/g, "<n>").replace(/https?:\/\/[^\s]+/g, "<url>").trim();
|
|
217
|
+
const stackFrame = stackTrace ? stackTrace.split(`
|
|
218
|
+
`).slice(0, 3).join("|") : "";
|
|
219
|
+
const raw = `${level}|${service ?? ""}|${normalized}|${stackFrame}`;
|
|
220
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
221
|
+
}
|
|
222
|
+
function upsertIssue(db, data) {
|
|
223
|
+
const fingerprint = computeFingerprint(data.level, data.service ?? null, data.message, data.stack_trace);
|
|
224
|
+
return db.prepare(`
|
|
225
|
+
INSERT INTO issues (project_id, fingerprint, level, service, message_template)
|
|
226
|
+
VALUES ($project_id, $fingerprint, $level, $service, $message_template)
|
|
227
|
+
ON CONFLICT(project_id, fingerprint) DO UPDATE SET
|
|
228
|
+
count = count + 1,
|
|
229
|
+
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
|
230
|
+
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END
|
|
231
|
+
RETURNING *
|
|
232
|
+
`).get({
|
|
233
|
+
$project_id: data.project_id ?? null,
|
|
234
|
+
$fingerprint: fingerprint,
|
|
235
|
+
$level: data.level,
|
|
236
|
+
$service: data.service ?? null,
|
|
237
|
+
$message_template: data.message.slice(0, 500)
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function listIssues(db, projectId, status, limit = 50) {
|
|
241
|
+
const conditions = [];
|
|
242
|
+
const params = { $limit: limit };
|
|
243
|
+
if (projectId) {
|
|
244
|
+
conditions.push("project_id = $p");
|
|
245
|
+
params.$p = projectId;
|
|
246
|
+
}
|
|
247
|
+
if (status) {
|
|
248
|
+
conditions.push("status = $status");
|
|
249
|
+
params.$status = status;
|
|
250
|
+
}
|
|
251
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
252
|
+
return db.prepare(`SELECT * FROM issues ${where} ORDER BY last_seen DESC LIMIT $limit`).all(params);
|
|
253
|
+
}
|
|
254
|
+
function getIssue(db, id) {
|
|
255
|
+
return db.prepare("SELECT * FROM issues WHERE id = $id").get({ $id: id });
|
|
256
|
+
}
|
|
257
|
+
function updateIssueStatus(db, id, status) {
|
|
258
|
+
return db.prepare("UPDATE issues SET status = $status WHERE id = $id RETURNING *").get({ $id: id, $status: status });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/lib/alerts.ts
|
|
262
|
+
function createAlertRule(db, data) {
|
|
263
|
+
return db.prepare(`
|
|
264
|
+
INSERT INTO alert_rules (project_id, name, service, level, threshold_count, window_seconds, action, webhook_url)
|
|
265
|
+
VALUES ($project_id, $name, $service, $level, $threshold_count, $window_seconds, $action, $webhook_url)
|
|
266
|
+
RETURNING *
|
|
267
|
+
`).get({
|
|
268
|
+
$project_id: data.project_id,
|
|
269
|
+
$name: data.name,
|
|
270
|
+
$service: data.service ?? null,
|
|
271
|
+
$level: data.level ?? "error",
|
|
272
|
+
$threshold_count: data.threshold_count ?? 10,
|
|
273
|
+
$window_seconds: data.window_seconds ?? 60,
|
|
274
|
+
$action: data.action ?? "webhook",
|
|
275
|
+
$webhook_url: data.webhook_url ?? null
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function listAlertRules(db, projectId) {
|
|
279
|
+
if (projectId) {
|
|
280
|
+
return db.prepare("SELECT * FROM alert_rules WHERE project_id = $p ORDER BY created_at DESC").all({ $p: projectId });
|
|
281
|
+
}
|
|
282
|
+
return db.prepare("SELECT * FROM alert_rules ORDER BY created_at DESC").all();
|
|
283
|
+
}
|
|
284
|
+
function updateAlertRule(db, id, data) {
|
|
285
|
+
const fields = Object.keys(data).map((k) => `${k} = $${k}`).join(", ");
|
|
286
|
+
if (!fields)
|
|
287
|
+
return db.prepare("SELECT * FROM alert_rules WHERE id = $id").get({ $id: id });
|
|
288
|
+
const params = Object.fromEntries(Object.entries(data).map(([k, v]) => [`$${k}`, v]));
|
|
289
|
+
params.$id = id;
|
|
290
|
+
return db.prepare(`UPDATE alert_rules SET ${fields} WHERE id = $id RETURNING *`).get(params);
|
|
291
|
+
}
|
|
292
|
+
function deleteAlertRule(db, id) {
|
|
293
|
+
db.run("DELETE FROM alert_rules WHERE id = $id", { $id: id });
|
|
294
|
+
}
|
|
295
|
+
async function evaluateAlerts(db, projectId, service, level) {
|
|
296
|
+
const rules = db.prepare(`
|
|
297
|
+
SELECT * FROM alert_rules
|
|
298
|
+
WHERE project_id = $p AND level = $level AND enabled = 1
|
|
299
|
+
AND ($service IS NULL OR service IS NULL OR service = $service)
|
|
300
|
+
`).all({ $p: projectId, $level: level, $service: service });
|
|
301
|
+
for (const rule of rules) {
|
|
302
|
+
const since = new Date(Date.now() - rule.window_seconds * 1000).toISOString();
|
|
303
|
+
const conditions = ["project_id = $p", "level = $level", "timestamp >= $since"];
|
|
304
|
+
const params = { $p: projectId, $level: rule.level, $since: since };
|
|
305
|
+
if (rule.service) {
|
|
306
|
+
conditions.push("service = $service");
|
|
307
|
+
params.$service = rule.service;
|
|
308
|
+
}
|
|
309
|
+
const { count } = db.prepare(`SELECT COUNT(*) as count FROM logs WHERE ${conditions.join(" AND ")}`).get(params);
|
|
310
|
+
if (count >= rule.threshold_count) {
|
|
311
|
+
await fireAlert(db, rule, count);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function fireAlert(db, rule, count) {
|
|
316
|
+
if (rule.last_fired_at) {
|
|
317
|
+
const lastFired = new Date(rule.last_fired_at).getTime();
|
|
318
|
+
if (Date.now() - lastFired < rule.window_seconds * 1000)
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
db.run("UPDATE alert_rules SET last_fired_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = $id", { $id: rule.id });
|
|
322
|
+
const payload = {
|
|
323
|
+
alert: rule.name,
|
|
324
|
+
project_id: rule.project_id,
|
|
325
|
+
level: rule.level,
|
|
326
|
+
service: rule.service,
|
|
327
|
+
count,
|
|
328
|
+
threshold: rule.threshold_count,
|
|
329
|
+
window_seconds: rule.window_seconds,
|
|
330
|
+
fired_at: new Date().toISOString()
|
|
331
|
+
};
|
|
332
|
+
if (rule.action === "webhook" && rule.webhook_url) {
|
|
333
|
+
try {
|
|
334
|
+
await fetch(rule.webhook_url, {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "Content-Type": "application/json" },
|
|
337
|
+
body: JSON.stringify(payload)
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error(`Alert webhook failed for rule ${rule.id}:`, err);
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
console.warn(`[ALERT] ${rule.name}:`, JSON.stringify(payload));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/lib/ingest.ts
|
|
348
|
+
var ERROR_LEVELS = new Set(["warn", "error", "fatal"]);
|
|
349
|
+
function ingestLog(db, entry) {
|
|
350
|
+
const stmt = db.prepare(`
|
|
351
|
+
INSERT INTO logs (project_id, page_id, level, source, service, message, trace_id, session_id, agent, url, stack_trace, metadata)
|
|
352
|
+
VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
|
|
353
|
+
RETURNING *
|
|
354
|
+
`);
|
|
355
|
+
const row = stmt.get({
|
|
356
|
+
$project_id: entry.project_id ?? null,
|
|
357
|
+
$page_id: entry.page_id ?? null,
|
|
358
|
+
$level: entry.level,
|
|
359
|
+
$source: entry.source ?? "sdk",
|
|
360
|
+
$service: entry.service ?? null,
|
|
361
|
+
$message: entry.message,
|
|
362
|
+
$trace_id: entry.trace_id ?? null,
|
|
363
|
+
$session_id: entry.session_id ?? null,
|
|
364
|
+
$agent: entry.agent ?? null,
|
|
365
|
+
$url: entry.url ?? null,
|
|
366
|
+
$stack_trace: entry.stack_trace ?? null,
|
|
367
|
+
$metadata: entry.metadata ? JSON.stringify(entry.metadata) : null
|
|
368
|
+
});
|
|
369
|
+
if (ERROR_LEVELS.has(entry.level)) {
|
|
370
|
+
if (entry.project_id) {
|
|
371
|
+
upsertIssue(db, { project_id: entry.project_id, level: entry.level, service: entry.service, message: entry.message, stack_trace: entry.stack_trace });
|
|
372
|
+
evaluateAlerts(db, entry.project_id, entry.service ?? null, entry.level).catch(() => {});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return row;
|
|
376
|
+
}
|
|
377
|
+
function ingestBatch(db, entries, sharedTraceId) {
|
|
378
|
+
if (sharedTraceId) {
|
|
379
|
+
entries = entries.map((e) => e.trace_id ? e : { ...e, trace_id: sharedTraceId });
|
|
380
|
+
}
|
|
381
|
+
const insert = db.prepare(`
|
|
382
|
+
INSERT INTO logs (project_id, page_id, level, source, service, message, trace_id, session_id, agent, url, stack_trace, metadata)
|
|
383
|
+
VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
|
|
384
|
+
RETURNING *
|
|
385
|
+
`);
|
|
386
|
+
const tx = db.transaction((items) => items.map((entry) => insert.get({
|
|
387
|
+
$project_id: entry.project_id ?? null,
|
|
388
|
+
$page_id: entry.page_id ?? null,
|
|
389
|
+
$level: entry.level,
|
|
390
|
+
$source: entry.source ?? "sdk",
|
|
391
|
+
$service: entry.service ?? null,
|
|
392
|
+
$message: entry.message,
|
|
393
|
+
$trace_id: entry.trace_id ?? null,
|
|
394
|
+
$session_id: entry.session_id ?? null,
|
|
395
|
+
$agent: entry.agent ?? null,
|
|
396
|
+
$url: entry.url ?? null,
|
|
397
|
+
$stack_trace: entry.stack_trace ?? null,
|
|
398
|
+
$metadata: entry.metadata ? JSON.stringify(entry.metadata) : null
|
|
399
|
+
})));
|
|
400
|
+
const rows = tx(entries);
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
if (ERROR_LEVELS.has(entry.level) && entry.project_id) {
|
|
403
|
+
upsertIssue(db, { project_id: entry.project_id, level: entry.level, service: entry.service, message: entry.message, stack_trace: entry.stack_trace });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return rows;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/lib/summarize.ts
|
|
410
|
+
function summarizeLogs(db, projectId, since, until) {
|
|
411
|
+
const conditions = ["level IN ('warn','error','fatal')"];
|
|
412
|
+
const params = {};
|
|
413
|
+
if (projectId) {
|
|
414
|
+
conditions.push("project_id = $project_id");
|
|
415
|
+
params.$project_id = projectId;
|
|
416
|
+
}
|
|
417
|
+
if (since) {
|
|
418
|
+
conditions.push("timestamp >= $since");
|
|
419
|
+
params.$since = parseTime(since) ?? since;
|
|
420
|
+
}
|
|
421
|
+
if (until) {
|
|
422
|
+
conditions.push("timestamp <= $until");
|
|
423
|
+
params.$until = parseTime(until) ?? until;
|
|
424
|
+
}
|
|
425
|
+
const where = `WHERE ${conditions.join(" AND ")}`;
|
|
426
|
+
const sql = `
|
|
427
|
+
SELECT project_id, service, page_id, level,
|
|
428
|
+
COUNT(*) as count,
|
|
429
|
+
MAX(timestamp) as latest
|
|
430
|
+
FROM logs ${where}
|
|
431
|
+
GROUP BY project_id, service, page_id, level
|
|
432
|
+
ORDER BY count DESC
|
|
433
|
+
`;
|
|
434
|
+
return db.prepare(sql).all(params);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/lib/projects.ts
|
|
438
|
+
function createProject(db, data) {
|
|
439
|
+
return db.prepare(`
|
|
440
|
+
INSERT INTO projects (name, github_repo, base_url, description)
|
|
441
|
+
VALUES ($name, $github_repo, $base_url, $description)
|
|
442
|
+
RETURNING *
|
|
443
|
+
`).get({
|
|
444
|
+
$name: data.name,
|
|
445
|
+
$github_repo: data.github_repo ?? null,
|
|
446
|
+
$base_url: data.base_url ?? null,
|
|
447
|
+
$description: data.description ?? null
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function listProjects(db) {
|
|
451
|
+
return db.prepare("SELECT * FROM projects ORDER BY created_at DESC").all();
|
|
452
|
+
}
|
|
453
|
+
function getProject(db, id) {
|
|
454
|
+
return db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: id });
|
|
455
|
+
}
|
|
456
|
+
function updateProject(db, id, data) {
|
|
457
|
+
const fields = Object.keys(data).map((k) => `${k} = $${k}`).join(", ");
|
|
458
|
+
if (!fields)
|
|
459
|
+
return getProject(db, id);
|
|
460
|
+
const params = Object.fromEntries(Object.entries(data).map(([k, v]) => [`$${k}`, v]));
|
|
461
|
+
params.$id = id;
|
|
462
|
+
return db.prepare(`UPDATE projects SET ${fields} WHERE id = $id RETURNING *`).get(params);
|
|
463
|
+
}
|
|
464
|
+
function createPage(db, data) {
|
|
465
|
+
return db.prepare(`
|
|
466
|
+
INSERT INTO pages (project_id, url, path, name)
|
|
467
|
+
VALUES ($project_id, $url, $path, $name)
|
|
468
|
+
ON CONFLICT(project_id, url) DO UPDATE SET name = excluded.name
|
|
469
|
+
RETURNING *
|
|
470
|
+
`).get({
|
|
471
|
+
$project_id: data.project_id,
|
|
472
|
+
$url: data.url,
|
|
473
|
+
$path: data.path ?? new URL(data.url).pathname,
|
|
474
|
+
$name: data.name ?? null
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function listPages(db, projectId) {
|
|
478
|
+
return db.prepare("SELECT * FROM pages WHERE project_id = $p ORDER BY created_at ASC").all({ $p: projectId });
|
|
479
|
+
}
|
|
480
|
+
function getPage(db, id) {
|
|
481
|
+
return db.prepare("SELECT * FROM pages WHERE id = $id").get({ $id: id });
|
|
482
|
+
}
|
|
483
|
+
function resolveProjectId(db, idOrName) {
|
|
484
|
+
if (!idOrName)
|
|
485
|
+
return null;
|
|
486
|
+
if (/^[0-9a-f]{8,}$/i.test(idOrName))
|
|
487
|
+
return idOrName;
|
|
488
|
+
const p = db.prepare("SELECT id FROM projects WHERE LOWER(name) = LOWER($n)").get({ $n: idOrName });
|
|
489
|
+
return p?.id ?? null;
|
|
490
|
+
}
|
|
491
|
+
function touchPage(db, id) {
|
|
492
|
+
db.run("UPDATE pages SET last_scanned_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = $id", { $id: id });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/lib/perf.ts
|
|
496
|
+
function saveSnapshot(db, data) {
|
|
497
|
+
return db.prepare(`
|
|
498
|
+
INSERT INTO performance_snapshots (project_id, page_id, url, lcp, fcp, cls, tti, ttfb, score, raw_audit)
|
|
499
|
+
VALUES ($project_id, $page_id, $url, $lcp, $fcp, $cls, $tti, $ttfb, $score, $raw_audit)
|
|
500
|
+
RETURNING *
|
|
501
|
+
`).get({
|
|
502
|
+
$project_id: data.project_id,
|
|
503
|
+
$page_id: data.page_id ?? null,
|
|
504
|
+
$url: data.url,
|
|
505
|
+
$lcp: data.lcp ?? null,
|
|
506
|
+
$fcp: data.fcp ?? null,
|
|
507
|
+
$cls: data.cls ?? null,
|
|
508
|
+
$tti: data.tti ?? null,
|
|
509
|
+
$ttfb: data.ttfb ?? null,
|
|
510
|
+
$score: data.score ?? null,
|
|
511
|
+
$raw_audit: data.raw_audit ?? null
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function getLatestSnapshot(db, projectId, pageId) {
|
|
515
|
+
if (pageId) {
|
|
516
|
+
return db.prepare("SELECT * FROM performance_snapshots WHERE project_id = $p AND page_id = $pg ORDER BY timestamp DESC LIMIT 1").get({ $p: projectId, $pg: pageId });
|
|
517
|
+
}
|
|
518
|
+
return db.prepare("SELECT * FROM performance_snapshots WHERE project_id = $p ORDER BY timestamp DESC LIMIT 1").get({ $p: projectId });
|
|
519
|
+
}
|
|
520
|
+
function getPerfTrend(db, projectId, pageId, since, limit = 50) {
|
|
521
|
+
const conditions = ["project_id = $p"];
|
|
522
|
+
const params = { $p: projectId, $limit: limit };
|
|
523
|
+
if (pageId) {
|
|
524
|
+
conditions.push("page_id = $pg");
|
|
525
|
+
params.$pg = pageId;
|
|
526
|
+
}
|
|
527
|
+
if (since) {
|
|
528
|
+
conditions.push("timestamp >= $since");
|
|
529
|
+
params.$since = since;
|
|
530
|
+
}
|
|
531
|
+
return db.prepare(`SELECT * FROM performance_snapshots WHERE ${conditions.join(" AND ")} ORDER BY timestamp DESC LIMIT $limit`).all(params);
|
|
532
|
+
}
|
|
533
|
+
function scoreLabel(score) {
|
|
534
|
+
if (score === null)
|
|
535
|
+
return "unknown";
|
|
536
|
+
if (score >= 90)
|
|
537
|
+
return "green";
|
|
538
|
+
if (score >= 50)
|
|
539
|
+
return "yellow";
|
|
540
|
+
return "red";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export { getDb, listIssues, getIssue, updateIssueStatus, createAlertRule, listAlertRules, updateAlertRule, deleteAlertRule, ingestLog, ingestBatch, summarizeLogs, createProject, listProjects, getProject, updateProject, createPage, listPages, getPage, resolveProjectId, touchPage, saveSnapshot, getLatestSnapshot, getPerfTrend, scoreLabel };
|
package/dist/mcp/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
scoreLabel,
|
|
22
22
|
summarizeLogs,
|
|
23
23
|
updateIssueStatus
|
|
24
|
-
} from "../index-
|
|
24
|
+
} from "../index-t97ttm0a.js";
|
|
25
25
|
import {
|
|
26
26
|
createJob,
|
|
27
27
|
listJobs
|
|
@@ -28583,10 +28583,16 @@ server.tool("log_push_batch", {
|
|
|
28583
28583
|
service: exports_external.string().optional(),
|
|
28584
28584
|
trace_id: exports_external.string().optional(),
|
|
28585
28585
|
metadata: exports_external.record(exports_external.unknown()).optional()
|
|
28586
|
-
}))
|
|
28587
|
-
|
|
28588
|
-
|
|
28589
|
-
|
|
28586
|
+
})),
|
|
28587
|
+
trace_id: exports_external.string().optional().describe("Shared trace_id applied to all entries that don't have their own trace_id"),
|
|
28588
|
+
project_id: exports_external.string().optional().describe("Shared project_id applied to all entries (individual entry project_id takes precedence)")
|
|
28589
|
+
}, ({ entries, trace_id, project_id }) => {
|
|
28590
|
+
const mapped = entries.map((e) => ({
|
|
28591
|
+
...e,
|
|
28592
|
+
project_id: rp(e.project_id ?? project_id)
|
|
28593
|
+
}));
|
|
28594
|
+
const rows = ingestBatch(db, mapped, trace_id);
|
|
28595
|
+
return { content: [{ type: "text", text: `Logged ${rows.length} entries${trace_id ? ` (trace: ${trace_id})` : ""}` }] };
|
|
28590
28596
|
});
|
|
28591
28597
|
server.tool("log_search", {
|
|
28592
28598
|
project_id: exports_external.string().optional(),
|
|
@@ -28770,5 +28776,24 @@ server.tool("delete_alert_rule", { id: exports_external.string() }, ({ id }) =>
|
|
|
28770
28776
|
server.tool("get_health", {}, () => ({
|
|
28771
28777
|
content: [{ type: "text", text: JSON.stringify(getHealth(db)) }]
|
|
28772
28778
|
}));
|
|
28779
|
+
server.tool("log_stats", {
|
|
28780
|
+
project_id: exports_external.string().optional().describe("Project name or ID (scope stats to a project)")
|
|
28781
|
+
}, (args) => {
|
|
28782
|
+
const projectId = rp(args.project_id);
|
|
28783
|
+
const pFilter = projectId ? `WHERE project_id = ?` : "";
|
|
28784
|
+
const pAnd = projectId ? `AND project_id = ?` : "";
|
|
28785
|
+
const pParam = projectId ? [projectId] : [];
|
|
28786
|
+
const total = db.query(`SELECT COUNT(*) as c FROM logs ${pFilter}`).get(...pParam).c;
|
|
28787
|
+
const oldest = db.query(`SELECT MIN(timestamp) as t FROM logs ${pFilter}`).get(...pParam).t;
|
|
28788
|
+
const newest = db.query(`SELECT MAX(timestamp) as t FROM logs ${pFilter}`).get(...pParam).t;
|
|
28789
|
+
const byLevel = db.query(`SELECT level, COUNT(*) as c FROM logs ${pFilter} GROUP BY level ORDER BY c DESC`).all(...pParam);
|
|
28790
|
+
const topServices = db.query(`SELECT COALESCE(service, '-') as service, COUNT(*) as c FROM logs ${pFilter} GROUP BY service ORDER BY c DESC LIMIT 5`).all(...pParam);
|
|
28791
|
+
const days = db.query(`SELECT strftime('%Y-%m-%d', timestamp) as day, COUNT(*) as c FROM logs WHERE timestamp >= datetime('now', '-7 days') ${pAnd} GROUP BY day ORDER BY day`).all(...pParam);
|
|
28792
|
+
const errors3 = (byLevel.find((r) => r.level === "error")?.c ?? 0) + (byLevel.find((r) => r.level === "fatal")?.c ?? 0);
|
|
28793
|
+
const error_rate_pct = total > 0 ? parseFloat((errors3 / total * 100).toFixed(2)) : 0;
|
|
28794
|
+
return {
|
|
28795
|
+
content: [{ type: "text", text: JSON.stringify({ total, oldest, newest, by_level: Object.fromEntries(byLevel.map((r) => [r.level, r.c])), top_services: topServices, last_7_days: days, error_rate_pct }) }]
|
|
28796
|
+
};
|
|
28797
|
+
});
|
|
28773
28798
|
var transport = new StdioServerTransport;
|
|
28774
28799
|
await server.connect(transport);
|
package/dist/server/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
setPageAuth,
|
|
7
7
|
setRetentionPolicy,
|
|
8
8
|
startScheduler
|
|
9
|
-
} from "../index-
|
|
9
|
+
} from "../index-2sbhn1ye.js";
|
|
10
10
|
import {
|
|
11
11
|
getHealth
|
|
12
12
|
} from "../index-xjn8gam3.js";
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
updateAlertRule,
|
|
32
32
|
updateIssueStatus,
|
|
33
33
|
updateProject
|
|
34
|
-
} from "../index-
|
|
34
|
+
} from "../index-t97ttm0a.js";
|
|
35
35
|
import {
|
|
36
36
|
createJob,
|
|
37
37
|
deleteJob,
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -346,6 +346,63 @@ program.command("export")
|
|
|
346
346
|
}
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
+
// ── logs stats ────────────────────────────────────────────
|
|
350
|
+
program.command("stats")
|
|
351
|
+
.description("Volume overview: count, DB size, timeline, top services, error rate")
|
|
352
|
+
.option("--project <name|id>", "Scope to a project")
|
|
353
|
+
.action((opts) => {
|
|
354
|
+
const db = getDb()
|
|
355
|
+
const projectId = resolveProject(opts.project)
|
|
356
|
+
const pFilter = projectId ? `WHERE project_id = '${projectId.replace(/'/g, "''")}'` : ""
|
|
357
|
+
const pAnd = projectId ? `AND project_id = '${projectId.replace(/'/g, "''")}'` : ""
|
|
358
|
+
|
|
359
|
+
const total = (db.query(`SELECT COUNT(*) as c FROM logs ${pFilter}`).get() as { c: number }).c
|
|
360
|
+
const oldest = (db.query(`SELECT MIN(timestamp) as t FROM logs ${pFilter}`).get() as { t: string | null }).t
|
|
361
|
+
const newest = (db.query(`SELECT MAX(timestamp) as t FROM logs ${pFilter}`).get() as { t: string | null }).t
|
|
362
|
+
|
|
363
|
+
const byLevel = db.query(`SELECT level, COUNT(*) as c FROM logs ${pFilter} GROUP BY level ORDER BY c DESC`)
|
|
364
|
+
.all() as { level: string; c: number }[]
|
|
365
|
+
|
|
366
|
+
const topServices = db.query(
|
|
367
|
+
`SELECT COALESCE(service, '-') as service, COUNT(*) as c FROM logs ${pFilter} GROUP BY service ORDER BY c DESC LIMIT 5`
|
|
368
|
+
).all() as { service: string; c: number }[]
|
|
369
|
+
|
|
370
|
+
// Last 7 days histogram
|
|
371
|
+
const days = db.query(
|
|
372
|
+
`SELECT strftime('%Y-%m-%d', timestamp) as day, COUNT(*) as c FROM logs WHERE timestamp >= datetime('now', '-7 days') ${pAnd} GROUP BY day ORDER BY day`
|
|
373
|
+
).all() as { day: string; c: number }[]
|
|
374
|
+
|
|
375
|
+
const errors = byLevel.find(r => r.level === "error")?.c ?? 0
|
|
376
|
+
const fatals = byLevel.find(r => r.level === "fatal")?.c ?? 0
|
|
377
|
+
const errorRate = total > 0 ? (((errors + fatals) / total) * 100).toFixed(2) : "0.00"
|
|
378
|
+
|
|
379
|
+
console.log(`\n${C.bold}Log Volume Stats${C.reset}${projectId ? ` [${opts.project}]` : ""}`)
|
|
380
|
+
console.log(` Total: ${total.toLocaleString()}`)
|
|
381
|
+
console.log(` Oldest: ${oldest?.slice(0, 19) ?? "-"}`)
|
|
382
|
+
console.log(` Newest: ${newest?.slice(0, 19) ?? "-"}`)
|
|
383
|
+
console.log(` Error rate: ${errorRate}% (${errors} errors, ${fatals} fatals)`)
|
|
384
|
+
|
|
385
|
+
if (byLevel.length) {
|
|
386
|
+
console.log(`\n${C.bold}By Level:${C.reset}`)
|
|
387
|
+
for (const r of byLevel) console.log(` ${colorLevel(r.level)} ${r.c.toLocaleString()}`)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (topServices.length) {
|
|
391
|
+
console.log(`\n${C.bold}Top Services:${C.reset}`)
|
|
392
|
+
for (const r of topServices) console.log(` ${C.cyan}${pad(r.service, 20)}${C.reset} ${r.c.toLocaleString()}`)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (days.length) {
|
|
396
|
+
const maxC = Math.max(...days.map(d => d.c))
|
|
397
|
+
console.log(`\n${C.bold}Last 7 Days:${C.reset}`)
|
|
398
|
+
for (const d of days) {
|
|
399
|
+
const bar = "█".repeat(Math.max(1, Math.round((d.c / maxC) * 20)))
|
|
400
|
+
console.log(` ${d.day} ${C.cyan}${bar}${C.reset} ${d.c.toLocaleString()}`)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
console.log("")
|
|
404
|
+
})
|
|
405
|
+
|
|
349
406
|
// ── logs health ───────────────────────────────────────────
|
|
350
407
|
program.command("health")
|
|
351
408
|
.description("Show server health and DB stats")
|