@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.
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createTestDb } from "../db/index.ts"
3
+ import { computeFingerprint, getIssue, listIssues, updateIssueStatus, upsertIssue } from "./issues.ts"
4
+
5
+ function seedProject(db: ReturnType<typeof createTestDb>) {
6
+ return db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
7
+ }
8
+
9
+ describe("computeFingerprint", () => {
10
+ it("returns consistent hash for same input", () => {
11
+ const a = computeFingerprint("error", "api", "DB connection failed")
12
+ const b = computeFingerprint("error", "api", "DB connection failed")
13
+ expect(a).toBe(b)
14
+ })
15
+
16
+ it("returns different hash for different messages", () => {
17
+ const a = computeFingerprint("error", "api", "timeout")
18
+ const b = computeFingerprint("error", "api", "DB error")
19
+ expect(a).not.toBe(b)
20
+ })
21
+
22
+ it("normalizes hex IDs in messages", () => {
23
+ const a = computeFingerprint("error", "api", "Error for id abc123def456")
24
+ const b = computeFingerprint("error", "api", "Error for id 000fffaabbcc")
25
+ expect(a).toBe(b)
26
+ })
27
+ })
28
+
29
+ describe("upsertIssue", () => {
30
+ it("creates a new issue", () => {
31
+ const db = createTestDb()
32
+ const p = seedProject(db)
33
+ const issue = upsertIssue(db, { project_id: p.id, level: "error", service: "api", message: "DB timeout" })
34
+ expect(issue.id).toBeTruthy()
35
+ expect(issue.count).toBe(1)
36
+ expect(issue.status).toBe("open")
37
+ })
38
+
39
+ it("increments count on duplicate", () => {
40
+ const db = createTestDb()
41
+ const p = seedProject(db)
42
+ upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
43
+ upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
44
+ const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
45
+ expect(issue.count).toBe(3)
46
+ })
47
+
48
+ it("reopens resolved issues", () => {
49
+ const db = createTestDb()
50
+ const p = seedProject(db)
51
+ const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "err" })
52
+ updateIssueStatus(db, issue.id, "resolved")
53
+ const reopened = upsertIssue(db, { project_id: p.id, level: "error", message: "err" })
54
+ expect(reopened.status).toBe("open")
55
+ })
56
+ })
57
+
58
+ describe("listIssues", () => {
59
+ it("filters by project and status", () => {
60
+ const db = createTestDb()
61
+ const p = seedProject(db)
62
+ const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "database connection timed out" })
63
+ updateIssueStatus(db, issue.id, "resolved")
64
+ upsertIssue(db, { project_id: p.id, level: "error", message: "authentication service unavailable" })
65
+ expect(listIssues(db, p.id, "open")).toHaveLength(1)
66
+ expect(listIssues(db, p.id, "resolved")).toHaveLength(1)
67
+ expect(listIssues(db, p.id)).toHaveLength(2)
68
+ })
69
+ })
70
+
71
+ describe("updateIssueStatus", () => {
72
+ it("updates status", () => {
73
+ const db = createTestDb()
74
+ const p = seedProject(db)
75
+ const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "x" })
76
+ const updated = updateIssueStatus(db, issue.id, "ignored")
77
+ expect(updated?.status).toBe("ignored")
78
+ })
79
+ })
@@ -0,0 +1,70 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
3
+
4
+ export interface Issue {
5
+ id: string
6
+ project_id: string | null
7
+ fingerprint: string
8
+ level: string
9
+ service: string | null
10
+ message_template: string
11
+ first_seen: string
12
+ last_seen: string
13
+ count: number
14
+ status: "open" | "resolved" | "ignored"
15
+ }
16
+
17
+ export function computeFingerprint(level: string, service: string | null, message: string, stackTrace?: string | null): string {
18
+ // Normalize message: strip hex IDs, numbers, timestamps
19
+ const normalized = message
20
+ .replace(/[0-9a-f]{8,}/gi, "<id>")
21
+ .replace(/\d+/g, "<n>")
22
+ .replace(/https?:\/\/[^\s]+/g, "<url>")
23
+ .trim()
24
+ const stackFrame = stackTrace ? stackTrace.split("\n").slice(0, 3).join("|") : ""
25
+ const raw = `${level}|${service ?? ""}|${normalized}|${stackFrame}`
26
+ return createHash("sha256").update(raw).digest("hex").slice(0, 16)
27
+ }
28
+
29
+ export function upsertIssue(db: Database, data: {
30
+ project_id?: string
31
+ level: string
32
+ service?: string | null
33
+ message: string
34
+ stack_trace?: string | null
35
+ }): Issue {
36
+ const fingerprint = computeFingerprint(data.level, data.service ?? null, data.message, data.stack_trace)
37
+ return db.prepare(`
38
+ INSERT INTO issues (project_id, fingerprint, level, service, message_template)
39
+ VALUES ($project_id, $fingerprint, $level, $service, $message_template)
40
+ ON CONFLICT(project_id, fingerprint) DO UPDATE SET
41
+ count = count + 1,
42
+ last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
43
+ status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END
44
+ RETURNING *
45
+ `).get({
46
+ $project_id: data.project_id ?? null,
47
+ $fingerprint: fingerprint,
48
+ $level: data.level,
49
+ $service: data.service ?? null,
50
+ $message_template: data.message.slice(0, 500),
51
+ }) as Issue
52
+ }
53
+
54
+ export function listIssues(db: Database, projectId?: string, status?: string, limit = 50): Issue[] {
55
+ const conditions: string[] = []
56
+ const params: Record<string, unknown> = { $limit: limit }
57
+ if (projectId) { conditions.push("project_id = $p"); params.$p = projectId }
58
+ if (status) { conditions.push("status = $status"); params.$status = status }
59
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
60
+ return db.prepare(`SELECT * FROM issues ${where} ORDER BY last_seen DESC LIMIT $limit`).all(params) as Issue[]
61
+ }
62
+
63
+ export function getIssue(db: Database, id: string): Issue | null {
64
+ return db.prepare("SELECT * FROM issues WHERE id = $id").get({ $id: id }) as Issue | null
65
+ }
66
+
67
+ export function updateIssueStatus(db: Database, id: string, status: "open" | "resolved" | "ignored"): Issue | null {
68
+ return db.prepare("UPDATE issues SET status = $status WHERE id = $id RETURNING *")
69
+ .get({ $id: id, $status: status }) as Issue | null
70
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createTestDb } from "../db/index.ts"
3
+ import { deletePageAuth, getPageAuth, setPageAuth } from "./page-auth.ts"
4
+
5
+ function seedPage(db: ReturnType<typeof createTestDb>) {
6
+ const p = db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
7
+ const page = db.prepare("INSERT INTO pages (project_id, url) VALUES (?, 'https://app.com') RETURNING id").get(p.id) as { id: string }
8
+ return { projectId: p.id, pageId: page.id }
9
+ }
10
+
11
+ describe("page auth", () => {
12
+ it("sets and retrieves bearer auth", () => {
13
+ const db = createTestDb()
14
+ const { pageId } = seedPage(db)
15
+ setPageAuth(db, pageId, "bearer", "my-token-123")
16
+ const auth = getPageAuth(db, pageId)
17
+ expect(auth?.type).toBe("bearer")
18
+ expect(auth?.credentials).toBe("my-token-123")
19
+ })
20
+
21
+ it("credentials are encrypted at rest", () => {
22
+ const db = createTestDb()
23
+ const { pageId } = seedPage(db)
24
+ setPageAuth(db, pageId, "bearer", "secret-token")
25
+ const raw = db.prepare("SELECT credentials FROM page_auth WHERE page_id = ?").get(pageId) as { credentials: string }
26
+ // Raw value should NOT be the plaintext token
27
+ expect(raw.credentials).not.toBe("secret-token")
28
+ expect(raw.credentials).toContain(":") // IV:encrypted format
29
+ })
30
+
31
+ it("upserts on duplicate page_id", () => {
32
+ const db = createTestDb()
33
+ const { pageId } = seedPage(db)
34
+ setPageAuth(db, pageId, "bearer", "token-v1")
35
+ setPageAuth(db, pageId, "bearer", "token-v2")
36
+ const auth = getPageAuth(db, pageId)
37
+ expect(auth?.credentials).toBe("token-v2")
38
+ const { c } = db.prepare("SELECT COUNT(*) as c FROM page_auth WHERE page_id = ?").get(pageId) as { c: number }
39
+ expect(c).toBe(1)
40
+ })
41
+
42
+ it("returns null for unknown page", () => {
43
+ const db = createTestDb()
44
+ expect(getPageAuth(db, "nope")).toBeNull()
45
+ })
46
+
47
+ it("deletes auth", () => {
48
+ const db = createTestDb()
49
+ const { pageId } = seedPage(db)
50
+ setPageAuth(db, pageId, "basic", "user:pass")
51
+ deletePageAuth(db, pageId)
52
+ expect(getPageAuth(db, pageId)).toBeNull()
53
+ })
54
+ })
@@ -0,0 +1,48 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
3
+
4
+ const SECRET_KEY = Buffer.from((process.env.LOGS_SECRET_KEY ?? "open-logs-default-key-32bytesXXX").padEnd(32).slice(0, 32))
5
+
6
+ export interface PageAuth {
7
+ id: string
8
+ page_id: string
9
+ type: "cookie" | "bearer" | "basic"
10
+ credentials: string
11
+ created_at: string
12
+ }
13
+
14
+ function encrypt(text: string): string {
15
+ const iv = randomBytes(16)
16
+ const cipher = createCipheriv("aes-256-cbc", SECRET_KEY, iv)
17
+ const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
18
+ return iv.toString("hex") + ":" + encrypted.toString("hex")
19
+ }
20
+
21
+ function decrypt(text: string): string {
22
+ const [ivHex, encHex] = text.split(":")
23
+ if (!ivHex || !encHex) return text
24
+ const iv = Buffer.from(ivHex, "hex")
25
+ const enc = Buffer.from(encHex, "hex")
26
+ const decipher = createDecipheriv("aes-256-cbc", SECRET_KEY, iv)
27
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8")
28
+ }
29
+
30
+ export function setPageAuth(db: Database, pageId: string, type: PageAuth["type"], credentials: string): PageAuth {
31
+ const encrypted = encrypt(credentials)
32
+ return db.prepare(`
33
+ INSERT INTO page_auth (page_id, type, credentials)
34
+ VALUES ($page_id, $type, $credentials)
35
+ ON CONFLICT(page_id) DO UPDATE SET type = excluded.type, credentials = excluded.credentials
36
+ RETURNING *
37
+ `).get({ $page_id: pageId, $type: type, $credentials: encrypted }) as PageAuth
38
+ }
39
+
40
+ export function getPageAuth(db: Database, pageId: string): { type: PageAuth["type"]; credentials: string } | null {
41
+ const row = db.prepare("SELECT * FROM page_auth WHERE page_id = $id").get({ $id: pageId }) as PageAuth | null
42
+ if (!row) return null
43
+ return { type: row.type, credentials: decrypt(row.credentials) }
44
+ }
45
+
46
+ export function deletePageAuth(db: Database, pageId: string): void {
47
+ db.run("DELETE FROM page_auth WHERE page_id = $id", { $id: pageId })
48
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createTestDb } from "../db/index.ts"
3
+ import { ingestBatch } from "./ingest.ts"
4
+ import { runRetentionForProject, setRetentionPolicy } from "./retention.ts"
5
+
6
+ function seedProject(db: ReturnType<typeof createTestDb>, name = "app") {
7
+ return db.prepare("INSERT INTO projects (name) VALUES (?) RETURNING id").get(name) as { id: string }
8
+ }
9
+
10
+ describe("retention", () => {
11
+ it("does nothing when under max_rows", () => {
12
+ const db = createTestDb()
13
+ const p = seedProject(db)
14
+ ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "info" as const, message: "x", project_id: p.id })))
15
+ const result = runRetentionForProject(db, p.id)
16
+ expect(result.deleted).toBe(0)
17
+ })
18
+
19
+ it("enforces max_rows", () => {
20
+ const db = createTestDb()
21
+ const p = seedProject(db)
22
+ setRetentionPolicy(db, p.id, { max_rows: 3 })
23
+ ingestBatch(db, Array.from({ length: 10 }, () => ({ level: "info" as const, message: "x", project_id: p.id })))
24
+ runRetentionForProject(db, p.id)
25
+ const count = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = ?").get(p.id) as { c: number }).c
26
+ expect(count).toBeLessThanOrEqual(3)
27
+ })
28
+
29
+ it("returns 0 for unknown project", () => {
30
+ const db = createTestDb()
31
+ expect(runRetentionForProject(db, "nope").deleted).toBe(0)
32
+ })
33
+
34
+ it("setRetentionPolicy updates project config", () => {
35
+ const db = createTestDb()
36
+ const p = seedProject(db)
37
+ setRetentionPolicy(db, p.id, { max_rows: 500, debug_ttl_hours: 1 })
38
+ const proj = db.prepare("SELECT max_rows, debug_ttl_hours FROM projects WHERE id = ?").get(p.id) as { max_rows: number; debug_ttl_hours: number }
39
+ expect(proj.max_rows).toBe(500)
40
+ expect(proj.debug_ttl_hours).toBe(1)
41
+ })
42
+ })
@@ -0,0 +1,62 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ interface RetentionConfig {
4
+ max_rows: number
5
+ debug_ttl_hours: number
6
+ info_ttl_hours: number
7
+ warn_ttl_hours: number
8
+ error_ttl_hours: number
9
+ }
10
+
11
+ const TTL_BY_LEVEL: Record<string, keyof RetentionConfig> = {
12
+ debug: "debug_ttl_hours",
13
+ info: "info_ttl_hours",
14
+ warn: "warn_ttl_hours",
15
+ error: "error_ttl_hours",
16
+ fatal: "error_ttl_hours",
17
+ }
18
+
19
+ export function runRetentionForProject(db: Database, projectId: string): { deleted: number } {
20
+ const project = db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: projectId }) as (RetentionConfig & { id: string }) | null
21
+ if (!project) return { deleted: 0 }
22
+
23
+ let deleted = 0
24
+
25
+ // TTL enforcement per level
26
+ for (const [level, configKey] of Object.entries(TTL_BY_LEVEL)) {
27
+ const ttlHours = project[configKey] as number
28
+ const cutoff = new Date(Date.now() - ttlHours * 3600 * 1000).toISOString()
29
+ const before = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p AND level = $level AND timestamp < $cutoff").get({ $p: projectId, $level: level, $cutoff: cutoff }) as { c: number }).c
30
+ if (before > 0) {
31
+ db.prepare("DELETE FROM logs WHERE project_id = $p AND level = $level AND timestamp < $cutoff").run({ $p: projectId, $level: level, $cutoff: cutoff })
32
+ deleted += before
33
+ }
34
+ }
35
+
36
+ // max_rows enforcement
37
+ const total = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p").get({ $p: projectId }) as { c: number }).c
38
+ if (total > project.max_rows) {
39
+ const toDelete = total - project.max_rows
40
+ db.prepare(`DELETE FROM logs WHERE id IN (SELECT id FROM logs WHERE project_id = $p ORDER BY timestamp ASC LIMIT ${toDelete})`).run({ $p: projectId })
41
+ deleted += toDelete
42
+ }
43
+
44
+ return { deleted }
45
+ }
46
+
47
+ export function runRetentionAll(db: Database): { deleted: number; projects: number } {
48
+ const projects = db.prepare("SELECT id FROM projects").all() as { id: string }[]
49
+ let deleted = 0
50
+ for (const p of projects) {
51
+ deleted += runRetentionForProject(db, p.id).deleted
52
+ }
53
+ return { deleted, projects: projects.length }
54
+ }
55
+
56
+ export function setRetentionPolicy(db: Database, projectId: string, config: Partial<RetentionConfig>): void {
57
+ const fields = Object.keys(config).map(k => `${k} = $${k}`).join(", ")
58
+ if (!fields) return
59
+ const params = Object.fromEntries(Object.entries(config).map(([k, v]) => [`$${k}`, v]))
60
+ params.$id = projectId
61
+ db.prepare(`UPDATE projects SET ${fields} WHERE id = $id`).run(params)
62
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import { ingestBatch } from "./ingest.ts"
3
+ import { getPageAuth } from "./page-auth.ts"
3
4
  import { saveSnapshot } from "./perf.ts"
4
5
  import { getPage, touchPage } from "./projects.ts"
5
6
  import type { LogEntry } from "../types/index.ts"
@@ -17,9 +18,27 @@ export async function scanPage(db: Database, projectId: string, pageId: string,
17
18
 
18
19
  const { chromium } = await import("playwright")
19
20
  const browser = await chromium.launch({ headless: true })
20
- const context = await browser.newContext({
21
+
22
+ // Apply page auth if configured
23
+ const auth = getPageAuth(db, pageId)
24
+ const contextOptions: Parameters<typeof browser.newContext>[0] = {
21
25
  userAgent: "Mozilla/5.0 (@hasna/logs scanner) AppleWebKit/537.36",
22
- })
26
+ }
27
+ if (auth?.type === "cookie") {
28
+ try { contextOptions.storageState = JSON.parse(auth.credentials) } catch { /* invalid */ }
29
+ } else if (auth?.type === "basic") {
30
+ const [username, password] = auth.credentials.split(":")
31
+ contextOptions.httpCredentials = { username: username ?? "", password: password ?? "" }
32
+ }
33
+
34
+ const context = await browser.newContext(contextOptions)
35
+
36
+ if (auth?.type === "bearer") {
37
+ await context.route("**/*", (route) => {
38
+ route.continue({ headers: { ...route.request().headers(), Authorization: `Bearer ${auth.credentials}` } })
39
+ })
40
+ }
41
+
23
42
  const browserPage = await context.newPage()
24
43
 
25
44
  const collected: LogEntry[] = []
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite"
2
2
  import cron from "node-cron"
3
3
  import { finishScanRun, createScanRun, listJobs, updateJob } from "./jobs.ts"
4
4
  import { listPages } from "./projects.ts"
5
+ import { runRetentionAll } from "./retention.ts"
5
6
  import { scanPage } from "./scanner.ts"
6
7
 
7
8
  const tasks = new Map<string, cron.ScheduledTask>()
@@ -11,6 +12,11 @@ export function startScheduler(db: Database): void {
11
12
  for (const job of jobs) {
12
13
  scheduleJob(db, job.id, job.schedule, job.project_id, job.page_id ?? undefined)
13
14
  }
15
+ // Hourly retention runner
16
+ cron.schedule("0 * * * *", () => {
17
+ const result = runRetentionAll(db)
18
+ if (result.deleted > 0) console.log(`Retention: deleted ${result.deleted} logs across ${result.projects} project(s)`)
19
+ })
14
20
  console.log(`Scheduler started: ${tasks.size} job(s) active`)
15
21
  }
16
22