@hasna/logs 0.3.22 → 0.3.24

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.
@@ -6,7 +6,7 @@ import {
6
6
  setPageAuth,
7
7
  setRetentionPolicy,
8
8
  startScheduler
9
- } from "../index-ssqkc6nh.js";
9
+ } from "../index-pf8hpweg.js";
10
10
  import {
11
11
  exportToCsv,
12
12
  exportToJson
@@ -37,7 +37,7 @@ import {
37
37
  updateAlertRule,
38
38
  updateIssueStatus,
39
39
  updateProject
40
- } from "../index-5cj74qka.js";
40
+ } from "../index-g8f8kep6.js";
41
41
  import {
42
42
  createJob,
43
43
  deleteJob,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/logs",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
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",
@@ -40,12 +40,12 @@
40
40
  "author": "Andrei Hasna <andrei@hasna.com>",
41
41
  "license": "Apache-2.0",
42
42
  "dependencies": {
43
- "@hasna/cloud": "^0.1.24",
44
- "@modelcontextprotocol/sdk": "^1.29.0",
43
+ "@modelcontextprotocol/sdk": "^1.12.1",
45
44
  "commander": "^14.0.0",
46
45
  "hono": "^4.7.11",
47
46
  "ink": "^5.1.0",
48
47
  "node-cron": "^3.0.3",
48
+ "pg": "^8.20.0",
49
49
  "playwright": "^1.52.0",
50
50
  "react": "^19.1.0"
51
51
  },
@@ -53,6 +53,7 @@
53
53
  "@biomejs/biome": "^1.9.4",
54
54
  "@types/bun": "latest",
55
55
  "@types/node-cron": "^3.0.11",
56
+ "@types/pg": "^8.20.0",
56
57
  "@types/react": "^19.1.4",
57
58
  "typescript": "^5.9.3"
58
59
  }
package/sdk/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/logs-sdk",
3
3
  "version": "0.1.0",
4
- "description": "Zero-dependency fetch client for @hasna/logs \u2014 push logs, query, browse issues, perf snapshots",
4
+ "description": "Zero-dependency fetch client for @hasna/logs push logs, query, browse issues, perf snapshots",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -16,12 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "bun build src/index.ts --outdir dist --target browser"
18
18
  },
19
- "keywords": [
20
- "logs",
21
- "monitoring",
22
- "sdk",
23
- "ai-agents"
24
- ],
19
+ "keywords": ["logs", "monitoring", "sdk", "ai-agents"],
25
20
  "author": "Andrei Hasna <andrei@hasna.com>",
26
- "license": "Apache-2.0"
21
+ "license": "MIT"
27
22
  }
@@ -46,7 +46,7 @@ test("logs-mcp --help prints usage and exits without starting stdio transport",
46
46
 
47
47
  expect(result.exitCode).toBe(0)
48
48
  expect(result.stdout).toContain("Usage: logs-mcp [options]")
49
- expect(result.stdout).toContain("Start the @hasna/logs MCP server (stdio by default).")
49
+ expect(result.stdout).toContain("Start the @hasna/logs MCP server over stdio.")
50
50
  expect(result.stdout).not.toContain("Listening")
51
51
  expect(result.stderr.trim()).toBe("")
52
52
  })
package/src/db/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SqliteAdapter as Database } from "@hasna/cloud"
1
+ import { Database } from "bun:sqlite"
2
2
  import { join } from "node:path"
3
3
  import { existsSync, mkdirSync, cpSync } from "node:fs"
4
4
  import { migrateAlertRules } from "./migrations/001_alert_rules.ts"
@@ -0,0 +1,167 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { getDb } from "../db/index.ts"
3
+ import { PG_MIGRATIONS } from "../db/pg-migrations.ts"
4
+ import { PgAdapterAsync } from "./remote-storage.ts"
5
+
6
+ export const CLOUD_TABLES = [
7
+ "projects",
8
+ "pages",
9
+ "logs",
10
+ "scan_jobs",
11
+ "scan_runs",
12
+ "performance_snapshots",
13
+ "alert_rules",
14
+ "issues",
15
+ "page_auth",
16
+ "feedback",
17
+ ] as const
18
+
19
+ type Row = Record<string, unknown>
20
+
21
+ export function getCloudDatabaseUrl(): string | null {
22
+ return process.env.HASNA_LOGS_CLOUD_DATABASE_URL
23
+ ?? process.env.LOGS_CLOUD_DATABASE_URL
24
+ ?? null
25
+ }
26
+
27
+ export async function getCloudPg(): Promise<PgAdapterAsync> {
28
+ const url = getCloudDatabaseUrl()
29
+ if (!url) throw new Error("Missing HASNA_LOGS_CLOUD_DATABASE_URL or LOGS_CLOUD_DATABASE_URL")
30
+ return new PgAdapterAsync(url)
31
+ }
32
+
33
+ export async function runCloudMigrations(remote: PgAdapterAsync): Promise<void> {
34
+ for (const sql of PG_MIGRATIONS) await remote.run(sql)
35
+ }
36
+
37
+ export async function cloudPush(opts?: { tables?: string[] }): Promise<{ rows: number }> {
38
+ const remote = await getCloudPg()
39
+ try {
40
+ await runCloudMigrations(remote)
41
+ const db = getDb()
42
+ let rows = 0
43
+ for (const table of resolveTables(opts?.tables)) rows += await pushTable(db, remote, table)
44
+ return { rows }
45
+ } finally {
46
+ await remote.close()
47
+ }
48
+ }
49
+
50
+ export async function cloudPull(opts?: { tables?: string[] }): Promise<{ rows: number }> {
51
+ const remote = await getCloudPg()
52
+ try {
53
+ await runCloudMigrations(remote)
54
+ const db = getDb()
55
+ let rows = 0
56
+ for (const table of resolveTables(opts?.tables)) rows += await pullTable(remote, db, table)
57
+ return { rows }
58
+ } finally {
59
+ await remote.close()
60
+ }
61
+ }
62
+
63
+ export async function cloudSync(opts?: { tables?: string[] }): Promise<{ push: number; pull: number }> {
64
+ const push = await cloudPush(opts)
65
+ const pull = await cloudPull(opts)
66
+ return { push: push.rows, pull: pull.rows }
67
+ }
68
+
69
+ function resolveTables(tables?: string[]): string[] {
70
+ if (!tables || tables.length === 0) return [...CLOUD_TABLES]
71
+ const allowed = new Set<string>(CLOUD_TABLES)
72
+ const requested = tables.map(table => table.trim()).filter(Boolean)
73
+ const invalid = requested.filter(table => !allowed.has(table))
74
+ if (invalid.length > 0) throw new Error(`Unknown logs sync table(s): ${invalid.join(", ")}`)
75
+ return requested
76
+ }
77
+
78
+ function quoteIdent(identifier: string): string {
79
+ return `"${identifier.replace(/"/g, "\"\"")}"`
80
+ }
81
+
82
+ async function pushTable(db: Database, remote: PgAdapterAsync, table: string): Promise<number> {
83
+ const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as Row[]
84
+ if (rows.length === 0) return 0
85
+ const columns = await filterRemoteColumns(remote, table, Object.keys(rows[0]!))
86
+ await upsertPg(remote, table, columns, rows)
87
+ return rows.length
88
+ }
89
+
90
+ async function pullTable(remote: PgAdapterAsync, db: Database, table: string): Promise<number> {
91
+ const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]
92
+ if (rows.length === 0) return 0
93
+ const columns = filterLocalColumns(db, table, Object.keys(rows[0]!))
94
+ upsertSqlite(db, table, columns, rows)
95
+ return rows.length
96
+ }
97
+
98
+ async function filterRemoteColumns(remote: PgAdapterAsync, table: string, columns: string[]): Promise<string[]> {
99
+ const rows = await remote.all(`
100
+ SELECT column_name
101
+ FROM information_schema.columns
102
+ WHERE table_schema = 'public' AND table_name = ?
103
+ `, table) as Array<{ column_name: string }>
104
+ if (rows.length === 0) return columns
105
+ const allowed = new Set(rows.map(row => row.column_name))
106
+ return columns.filter(column => allowed.has(column))
107
+ }
108
+
109
+ function filterLocalColumns(db: Database, table: string, columns: string[]): string[] {
110
+ const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as Array<{ name: string }>
111
+ const allowed = new Set(rows.map(row => row.name))
112
+ return columns.filter(column => allowed.has(column))
113
+ }
114
+
115
+ async function upsertPg(remote: PgAdapterAsync, table: string, columns: string[], rows: Row[]): Promise<void> {
116
+ if (columns.length === 0) return
117
+ const columnList = columns.map(quoteIdent).join(", ")
118
+ const updateColumns = columns.filter(column => column !== "id")
119
+ const setClause = updateColumns.length > 0
120
+ ? updateColumns.map(column => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ")
121
+ : `${quoteIdent("id")} = EXCLUDED.${quoteIdent("id")}`
122
+
123
+ for (const row of rows) {
124
+ const placeholders = columns.map((_, index) => `$${index + 1}`).join(", ")
125
+ const params = columns.map(column => coerceForPg(table, column, row[column]))
126
+ await remote.run(
127
+ `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
128
+ ON CONFLICT (${quoteIdent("id")}) DO UPDATE SET ${setClause}`,
129
+ ...params,
130
+ )
131
+ }
132
+ }
133
+
134
+ function upsertSqlite(db: Database, table: string, columns: string[], rows: Row[]): void {
135
+ if (columns.length === 0) return
136
+ const columnList = columns.map(quoteIdent).join(", ")
137
+ const placeholders = columns.map(() => "?").join(", ")
138
+ const updateColumns = columns.filter(column => column !== "id")
139
+ const setClause = updateColumns.length > 0
140
+ ? updateColumns.map(column => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ")
141
+ : `${quoteIdent("id")} = excluded.${quoteIdent("id")}`
142
+ const statement = db.prepare(
143
+ `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
144
+ ON CONFLICT (${quoteIdent("id")}) DO UPDATE SET ${setClause}`,
145
+ )
146
+ const insert = db.transaction((batch: Row[]) => {
147
+ for (const row of batch) statement.run(...columns.map(column => coerceForSqlite(row[column])))
148
+ })
149
+ insert(rows)
150
+ }
151
+
152
+ function coerceForPg(table: string, column: string, value: unknown): unknown {
153
+ if (value === undefined) return null
154
+ if ((table === "scan_jobs" || table === "alert_rules") && column === "enabled") {
155
+ return Boolean(value)
156
+ }
157
+ return value
158
+ }
159
+
160
+ function coerceForSqlite(value: unknown): string | number | bigint | boolean | null | Uint8Array {
161
+ if (value === undefined || value === null) return null
162
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return value
163
+ if (value instanceof Date) return value.toISOString()
164
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value
165
+ if (typeof value === "object") return JSON.stringify(value)
166
+ return String(value)
167
+ }
package/src/lib/ingest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DbAdapter } from "@hasna/cloud"
1
+ import type { Database as DbAdapter } from "bun:sqlite"
2
2
  import type { LogEntry, LogRow } from "../types/index.ts"
3
3
  import { upsertIssue } from "./issues.ts"
4
4
  import { evaluateAlerts } from "./alerts.ts"
@@ -47,7 +47,7 @@ export function ingestBatch(db: DbAdapter, entries: LogEntry[], sharedTraceId?:
47
47
  VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
48
48
  RETURNING *
49
49
  `)
50
- // @hasna/cloud executes the callback inside the transaction immediately.
50
+ // Bun returns a callable transaction wrapper; invoke it to execute the batch.
51
51
  const rows = db.transaction(() =>
52
52
  entries.map(entry =>
53
53
  insert.get({
@@ -65,7 +65,7 @@ export function ingestBatch(db: DbAdapter, entries: LogEntry[], sharedTraceId?:
65
65
  $metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
66
66
  }) as LogRow
67
67
  )
68
- )
68
+ )()
69
69
 
70
70
  // Issue grouping for error-level entries (outside transaction for perf)
71
71
  for (const entry of entries) {
@@ -0,0 +1,45 @@
1
+ import pg from "pg"
2
+ import type { Pool } from "pg"
3
+
4
+ function translatePlaceholders(sql: string): string {
5
+ let index = 0
6
+ return sql.replace(/\?/g, () => `$${++index}`)
7
+ }
8
+
9
+ function normalizeParams(params: unknown[]): unknown[] {
10
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params
11
+ return flat.map(value => value === undefined ? null : value)
12
+ }
13
+
14
+ function sslConfigFor(connectionString: string): { rejectUnauthorized: boolean } | undefined {
15
+ return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true")
16
+ ? { rejectUnauthorized: false }
17
+ : undefined
18
+ }
19
+
20
+ export class PgAdapterAsync {
21
+ private readonly pool: Pool
22
+
23
+ constructor(connectionString: string) {
24
+ this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) })
25
+ }
26
+
27
+ async run(sql: string, ...params: unknown[]): Promise<{ changes: number }> {
28
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
29
+ return { changes: result.rowCount ?? 0 }
30
+ }
31
+
32
+ async get(sql: string, ...params: unknown[]): Promise<unknown> {
33
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
34
+ return result.rows[0] ?? null
35
+ }
36
+
37
+ async all(sql: string, ...params: unknown[]): Promise<unknown[]> {
38
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params))
39
+ return result.rows
40
+ }
41
+
42
+ async close(): Promise<void> {
43
+ await this.pool.end()
44
+ }
45
+ }
package/src/mcp/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
- import { registerCloudTools } from "@hasna/cloud"
5
4
  import { z } from "zod"
6
5
  import { getDb } from "../db/index.ts"
7
6
  import { exitIfMetadataRequest, PACKAGE_VERSION } from "../lib/package-meta.ts"
@@ -20,26 +19,21 @@ import { compare } from "../lib/compare.ts"
20
19
  import { getHealth } from "../lib/health.ts"
21
20
  import { getSessionContext } from "../lib/session-context.ts"
22
21
  import { parseTime } from "../lib/parse-time.ts"
22
+ import { cloudPull, cloudPush, cloudSync, getCloudDatabaseUrl, getCloudPg } from "../lib/cloud-sync.ts"
23
23
  import type { LogLevel, LogRow } from "../types/index.ts"
24
24
 
25
25
  exitIfMetadataRequest({
26
26
  name: "logs-mcp",
27
- description: "Start the @hasna/logs MCP server (stdio by default).",
28
- options: [
29
- " --http Serve MCP over Streamable HTTP (127.0.0.1)",
30
- " --port <number> HTTP port (default: 8820, env: MCP_HTTP_PORT)",
31
- ],
27
+ description: "Start the @hasna/logs MCP server over stdio.",
32
28
  })
33
29
 
34
30
  const db = getDb()
31
+ const server = new McpServer({ name: "logs", version: PACKAGE_VERSION })
35
32
 
36
- // --- in-memory agent registry (module-level for shared HTTP process) ---
33
+ // --- in-memory agent registry ---
37
34
  interface _LogsAgent { id: string; name: string; session_id?: string; last_seen_at: string; project_id?: string }
38
35
  const _logsAgents = new Map<string, _LogsAgent>()
39
36
 
40
- export function buildServer(): McpServer {
41
- const server = new McpServer({ name: "logs", version: PACKAGE_VERSION })
42
-
43
37
  const BRIEF_FIELDS: (keyof LogRow)[] = ["id", "timestamp", "level", "message", "service"]
44
38
 
45
39
  function applyBrief(rows: LogRow[], brief = true): unknown[] {
@@ -414,30 +408,33 @@ server.tool("list_agents", "List all registered agents.", {}, async () => {
414
408
  return { content: [{ type: "text" as const, text: JSON.stringify([..._logsAgents.values()]) }] }
415
409
  })
416
410
 
417
- registerCloudTools(server, "logs")
418
- return server
419
- }
411
+ server.tool("cloud_status", "Check configured logs PostgreSQL remote.", {}, async () => {
412
+ const url = getCloudDatabaseUrl()
413
+ if (!url) return { content: [{ type: "text" as const, text: "cloud: not configured" }] }
414
+ let cloud: Awaited<ReturnType<typeof getCloudPg>> | null = null
415
+ try {
416
+ cloud = await getCloudPg()
417
+ await cloud.get("SELECT 1 as ok")
418
+ const tables = await cloud.all("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename") as Array<{ tablename: string }>
419
+ return { content: [{ type: "text" as const, text: JSON.stringify({ connected: true, tables: tables.map(row => row.tablename) }) }] }
420
+ } catch (error) {
421
+ return { content: [{ type: "text" as const, text: String(error) }], isError: true }
422
+ } finally {
423
+ if (cloud) await cloud.close().catch(() => {})
424
+ }
425
+ })
420
426
 
421
- async function main(): Promise<void> {
422
- const { isHttpMode, resolveMcpHttpPort, startMcpHttpServer } = await import("./http.ts")
427
+ server.tool("cloud_push", "Push local logs data to PostgreSQL.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
428
+ return { content: [{ type: "text" as const, text: JSON.stringify(await cloudPush({ tables })) }] }
429
+ })
423
430
 
424
- if (isHttpMode()) {
425
- const handle = await startMcpHttpServer(buildServer, {
426
- port: resolveMcpHttpPort(),
427
- })
428
- process.on("SIGINT", () => void handle.close().finally(() => process.exit(0)))
429
- process.on("SIGTERM", () => void handle.close().finally(() => process.exit(0)))
430
- return
431
- }
431
+ server.tool("cloud_pull", "Pull logs data from PostgreSQL.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
432
+ return { content: [{ type: "text" as const, text: JSON.stringify(await cloudPull({ tables })) }] }
433
+ })
432
434
 
433
- const server = buildServer()
434
- const transport = new StdioServerTransport()
435
- await server.connect(transport)
436
- }
435
+ server.tool("cloud_sync", "Push local logs data, then pull remote rows.", { tables: z.array(z.string()).optional() }, async ({ tables }) => {
436
+ return { content: [{ type: "text" as const, text: JSON.stringify(await cloudSync({ tables })) }] }
437
+ })
437
438
 
438
- if (import.meta.main) {
439
- main().catch((err) => {
440
- console.error(err)
441
- process.exit(1)
442
- })
443
- }
439
+ const transport = new StdioServerTransport()
440
+ await server.connect(transport)