@hasna/logs 0.0.1

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,42 @@
1
+ import { Hono } from "hono"
2
+ import type { Database } from "bun:sqlite"
3
+ import { createPage, createProject, getProject, listPages, listProjects } from "../../lib/projects.ts"
4
+ import { syncGithubRepo } from "../../lib/github.ts"
5
+
6
+ export function projectsRoutes(db: Database) {
7
+ const app = new Hono()
8
+
9
+ app.post("/", async (c) => {
10
+ const body = await c.req.json()
11
+ if (!body.name) return c.json({ error: "name is required" }, 422)
12
+ const project = createProject(db, body)
13
+ return c.json(project, 201)
14
+ })
15
+
16
+ app.get("/", (c) => c.json(listProjects(db)))
17
+
18
+ app.get("/:id", (c) => {
19
+ const project = getProject(db, c.req.param("id"))
20
+ if (!project) return c.json({ error: "not found" }, 404)
21
+ return c.json(project)
22
+ })
23
+
24
+ app.post("/:id/pages", async (c) => {
25
+ const body = await c.req.json()
26
+ if (!body.url) return c.json({ error: "url is required" }, 422)
27
+ const page = createPage(db, { ...body, project_id: c.req.param("id") })
28
+ return c.json(page, 201)
29
+ })
30
+
31
+ app.get("/:id/pages", (c) => c.json(listPages(db, c.req.param("id"))))
32
+
33
+ app.post("/:id/sync-repo", async (c) => {
34
+ const project = getProject(db, c.req.param("id"))
35
+ if (!project) return c.json({ error: "not found" }, 404)
36
+ if (!project.github_repo) return c.json({ error: "no github_repo set" }, 422)
37
+ const updated = await syncGithubRepo(db, project)
38
+ return c.json(updated)
39
+ })
40
+
41
+ return app
42
+ }
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it, beforeEach } from "bun:test"
2
+ import { Hono } from "hono"
3
+ import { cors } from "hono/cors"
4
+ import { createTestDb } from "../db/index.ts"
5
+ import { logsRoutes } from "./routes/logs.ts"
6
+ import { projectsRoutes } from "./routes/projects.ts"
7
+ import { jobsRoutes } from "./routes/jobs.ts"
8
+ import { perfRoutes } from "./routes/perf.ts"
9
+
10
+ function buildApp() {
11
+ const db = createTestDb()
12
+ const app = new Hono()
13
+ app.use("*", cors())
14
+ app.route("/api/logs", logsRoutes(db))
15
+ app.route("/api/projects", projectsRoutes(db))
16
+ app.route("/api/jobs", jobsRoutes(db))
17
+ app.route("/api/perf", perfRoutes(db))
18
+ return { app, db }
19
+ }
20
+
21
+ describe("POST /api/logs", () => {
22
+ it("ingests a single log", async () => {
23
+ const { app } = buildApp()
24
+ const res = await app.request("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: "error", message: "boom" }) })
25
+ expect(res.status).toBe(201)
26
+ const body = await res.json() as { level: string; message: string }
27
+ expect(body.level).toBe("error")
28
+ expect(body.message).toBe("boom")
29
+ })
30
+
31
+ it("ingests a batch", async () => {
32
+ const { app } = buildApp()
33
+ const res = await app.request("/api/logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify([{ level: "info", message: "a" }, { level: "warn", message: "b" }]) })
34
+ expect(res.status).toBe(201)
35
+ const body = await res.json() as { inserted: number }
36
+ expect(body.inserted).toBe(2)
37
+ })
38
+ })
39
+
40
+ describe("GET /api/logs", () => {
41
+ it("lists logs", async () => {
42
+ const { app, db } = buildApp()
43
+ const { ingestBatch } = await import("../lib/ingest.ts")
44
+ ingestBatch(db, [{ level: "error", message: "e1" }, { level: "info", message: "i1" }])
45
+ const res = await app.request("/api/logs")
46
+ expect(res.status).toBe(200)
47
+ const body = await res.json() as unknown[]
48
+ expect(body.length).toBeGreaterThanOrEqual(2)
49
+ })
50
+
51
+ it("filters by level", async () => {
52
+ const { app, db } = buildApp()
53
+ const { ingestBatch } = await import("../lib/ingest.ts")
54
+ ingestBatch(db, [{ level: "error", message: "e1" }, { level: "info", message: "i1" }])
55
+ const res = await app.request("/api/logs?level=error")
56
+ const body = await res.json() as { level: string }[]
57
+ expect(body.every(r => r.level === "error")).toBe(true)
58
+ })
59
+
60
+ it("supports ?fields= projection", async () => {
61
+ const { app, db } = buildApp()
62
+ const { ingestLog } = await import("../lib/ingest.ts")
63
+ ingestLog(db, { level: "info", message: "hello" })
64
+ const res = await app.request("/api/logs?fields=level,message")
65
+ const body = await res.json() as Record<string, unknown>[]
66
+ expect(Object.keys(body[0]!).sort()).toEqual(["level", "message"].sort())
67
+ })
68
+ })
69
+
70
+ describe("GET /api/logs/tail", () => {
71
+ it("returns recent logs", async () => {
72
+ const { app, db } = buildApp()
73
+ const { ingestBatch } = await import("../lib/ingest.ts")
74
+ ingestBatch(db, Array.from({ length: 10 }, (_, i) => ({ level: "info" as const, message: `m${i}` })))
75
+ const res = await app.request("/api/logs/tail?n=5")
76
+ const body = await res.json() as unknown[]
77
+ expect(body).toHaveLength(5)
78
+ })
79
+ })
80
+
81
+ describe("GET /api/logs/summary", () => {
82
+ it("returns summary of errors/warns", async () => {
83
+ const { app, db } = buildApp()
84
+ const { ingestBatch } = await import("../lib/ingest.ts")
85
+ ingestBatch(db, [{ level: "error", message: "x", service: "api" }, { level: "warn", message: "y", service: "db" }])
86
+ const res = await app.request("/api/logs/summary")
87
+ const body = await res.json() as unknown[]
88
+ expect(body.length).toBeGreaterThan(0)
89
+ })
90
+ })
91
+
92
+ describe("GET /api/logs/:trace_id/context", () => {
93
+ it("returns logs for trace", async () => {
94
+ const { app, db } = buildApp()
95
+ const { ingestBatch } = await import("../lib/ingest.ts")
96
+ ingestBatch(db, [{ level: "info", message: "a", trace_id: "t99" }, { level: "error", message: "b", trace_id: "t99" }])
97
+ const res = await app.request("/api/logs/t99/context")
98
+ const body = await res.json() as unknown[]
99
+ expect(body).toHaveLength(2)
100
+ })
101
+ })
102
+
103
+ describe("POST /api/projects", () => {
104
+ it("creates a project", async () => {
105
+ const { app } = buildApp()
106
+ const res = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "myapp" }) })
107
+ expect(res.status).toBe(201)
108
+ const body = await res.json() as { name: string }
109
+ expect(body.name).toBe("myapp")
110
+ })
111
+
112
+ it("returns 422 without name", async () => {
113
+ const { app } = buildApp()
114
+ const res = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) })
115
+ expect(res.status).toBe(422)
116
+ })
117
+ })
118
+
119
+ describe("GET /api/projects", () => {
120
+ it("lists projects", async () => {
121
+ const { app } = buildApp()
122
+ await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "p1" }) })
123
+ const res = await app.request("/api/projects")
124
+ const body = await res.json() as unknown[]
125
+ expect(body.length).toBeGreaterThanOrEqual(1)
126
+ })
127
+ })
128
+
129
+ describe("POST /api/projects/:id/pages", () => {
130
+ it("registers a page", async () => {
131
+ const { app } = buildApp()
132
+ const pRes = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "app" }) })
133
+ const project = await pRes.json() as { id: string }
134
+ const res = await app.request(`/api/projects/${project.id}/pages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://app.com/home" }) })
135
+ expect(res.status).toBe(201)
136
+ const page = await res.json() as { url: string }
137
+ expect(page.url).toBe("https://app.com/home")
138
+ })
139
+
140
+ it("returns 422 without url", async () => {
141
+ const { app } = buildApp()
142
+ const pRes = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "app2" }) })
143
+ const project = await pRes.json() as { id: string }
144
+ const res = await app.request(`/api/projects/${project.id}/pages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) })
145
+ expect(res.status).toBe(422)
146
+ })
147
+ })
148
+
149
+ describe("jobs routes", () => {
150
+ it("creates and lists jobs", async () => {
151
+ const { app } = buildApp()
152
+ const pRes = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "appj" }) })
153
+ const { id } = await pRes.json() as { id: string }
154
+ const jRes = await app.request("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: id, schedule: "*/5 * * * *" }) })
155
+ expect(jRes.status).toBe(201)
156
+ const listRes = await app.request(`/api/jobs?project_id=${id}`)
157
+ const jobs = await listRes.json() as unknown[]
158
+ expect(jobs).toHaveLength(1)
159
+ })
160
+
161
+ it("returns 422 without required fields", async () => {
162
+ const { app } = buildApp()
163
+ const res = await app.request("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) })
164
+ expect(res.status).toBe(422)
165
+ })
166
+
167
+ it("deletes a job", async () => {
168
+ const { app } = buildApp()
169
+ const pRes = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "appd" }) })
170
+ const { id } = await pRes.json() as { id: string }
171
+ const jRes = await app.request("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: id, schedule: "*/5 * * * *" }) })
172
+ const job = await jRes.json() as { id: string }
173
+ const del = await app.request(`/api/jobs/${job.id}`, { method: "DELETE" })
174
+ expect(del.status).toBe(200)
175
+ })
176
+ })
177
+
178
+ describe("perf routes", () => {
179
+ it("returns 422 without project_id", async () => {
180
+ const { app } = buildApp()
181
+ const res = await app.request("/api/perf")
182
+ expect(res.status).toBe(422)
183
+ })
184
+
185
+ it("returns null when no snapshot exists", async () => {
186
+ const { app } = buildApp()
187
+ const pRes = await app.request("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "perf-app" }) })
188
+ const { id } = await pRes.json() as { id: string }
189
+ const res = await app.request(`/api/perf?project_id=${id}`)
190
+ expect(res.status).toBe(200)
191
+ const body = await res.json()
192
+ expect(body).toBeNull()
193
+ })
194
+ })
@@ -0,0 +1,119 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal"
2
+ export type LogSource = "sdk" | "script" | "scanner"
3
+
4
+ export interface LogEntry {
5
+ id?: string
6
+ timestamp?: string
7
+ project_id?: string
8
+ page_id?: string
9
+ level: LogLevel
10
+ source?: LogSource
11
+ service?: string
12
+ message: string
13
+ trace_id?: string
14
+ session_id?: string
15
+ agent?: string
16
+ url?: string
17
+ stack_trace?: string
18
+ metadata?: Record<string, unknown>
19
+ }
20
+
21
+ export interface LogRow {
22
+ id: string
23
+ timestamp: string
24
+ project_id: string | null
25
+ page_id: string | null
26
+ level: LogLevel
27
+ source: LogSource
28
+ service: string | null
29
+ message: string
30
+ trace_id: string | null
31
+ session_id: string | null
32
+ agent: string | null
33
+ url: string | null
34
+ stack_trace: string | null
35
+ metadata: string | null
36
+ }
37
+
38
+ export interface Project {
39
+ id: string
40
+ name: string
41
+ github_repo: string | null
42
+ base_url: string | null
43
+ description: string | null
44
+ github_description: string | null
45
+ github_branch: string | null
46
+ github_sha: string | null
47
+ last_synced_at: string | null
48
+ created_at: string
49
+ }
50
+
51
+ export interface Page {
52
+ id: string
53
+ project_id: string
54
+ url: string
55
+ path: string
56
+ name: string | null
57
+ last_scanned_at: string | null
58
+ created_at: string
59
+ }
60
+
61
+ export interface ScanJob {
62
+ id: string
63
+ project_id: string
64
+ page_id: string | null
65
+ schedule: string
66
+ enabled: number
67
+ last_run_at: string | null
68
+ created_at: string
69
+ }
70
+
71
+ export interface ScanRun {
72
+ id: string
73
+ job_id: string
74
+ page_id: string | null
75
+ started_at: string
76
+ finished_at: string | null
77
+ status: "running" | "completed" | "failed"
78
+ logs_collected: number
79
+ errors_found: number
80
+ perf_score: number | null
81
+ }
82
+
83
+ export interface PerformanceSnapshot {
84
+ id: string
85
+ timestamp: string
86
+ project_id: string
87
+ page_id: string | null
88
+ url: string
89
+ lcp: number | null
90
+ fcp: number | null
91
+ cls: number | null
92
+ tti: number | null
93
+ ttfb: number | null
94
+ score: number | null
95
+ raw_audit: string | null
96
+ }
97
+
98
+ export interface LogQuery {
99
+ project_id?: string
100
+ page_id?: string
101
+ level?: LogLevel | LogLevel[]
102
+ service?: string
103
+ since?: string
104
+ until?: string
105
+ text?: string
106
+ trace_id?: string
107
+ limit?: number
108
+ offset?: number
109
+ fields?: string[]
110
+ }
111
+
112
+ export interface LogSummary {
113
+ project_id: string | null
114
+ service: string | null
115
+ page_id: string | null
116
+ level: LogLevel
117
+ count: number
118
+ latest: string
119
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "allowJs": true,
9
+ "moduleResolution": "bundler",
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noEmit": true,
13
+ "strict": true,
14
+ "skipLibCheck": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUncheckedIndexedAccess": true,
17
+ "noImplicitOverride": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false,
20
+ "noPropertyAccessFromIndexSignature": false
21
+ }
22
+ }