function-logs.ts170 lines · main
| 1 | import { and, desc, eq, lt, sql } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { functionLogs, type FunctionLog } from '../db/schema.js'; |
| 5 | |
| 6 | /** |
| 7 | * Per-project function logs reader. Mirrors the audit-log pattern — fetch |
| 8 | * recent rows ordered newest-first, optionally filtered by function name |
| 9 | * or status. Pagination is offset-by-cursor via the `before` timestamp so |
| 10 | * we don't fall over a hot project's million-row log table. |
| 11 | * |
| 12 | * The `function_logs` table itself is populated by the runtime's |
| 13 | * log-fanout worker; this service is read-only. |
| 14 | */ |
| 15 | export interface ListFunctionLogsOpts { |
| 16 | /** Filter by exact function name. */ |
| 17 | readonly functionName?: string; |
| 18 | /** Filter by status: 'ok' | 'err'. */ |
| 19 | readonly status?: 'ok' | 'err'; |
| 20 | /** Page-after cursor — only rows with `createdAt < before` are returned. */ |
| 21 | readonly before?: Date; |
| 22 | /** Max rows to return (capped at 200). */ |
| 23 | readonly limit?: number; |
| 24 | } |
| 25 | |
| 26 | export async function listFunctionLogs( |
| 27 | projectId: string, |
| 28 | opts: ListFunctionLogsOpts = {}, |
| 29 | ): Promise<FunctionLog[]> { |
| 30 | const db = getDb(); |
| 31 | const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); |
| 32 | |
| 33 | const filters = [eq(functionLogs.projectId, projectId)]; |
| 34 | if (opts.functionName) { |
| 35 | filters.push(eq(functionLogs.functionName, opts.functionName)); |
| 36 | } |
| 37 | if (opts.status === 'ok' || opts.status === 'err') { |
| 38 | filters.push(eq(functionLogs.status, opts.status)); |
| 39 | } |
| 40 | if (opts.before) { |
| 41 | filters.push(lt(functionLogs.createdAt, opts.before)); |
| 42 | } |
| 43 | |
| 44 | return db |
| 45 | .select() |
| 46 | .from(functionLogs) |
| 47 | .where(filters.length === 1 ? filters[0] : and(...filters)) |
| 48 | .orderBy(desc(functionLogs.createdAt)) |
| 49 | .limit(limit); |
| 50 | } |
| 51 | |
| 52 | export interface HourlyInvocations { |
| 53 | /** ISO timestamp at the top of the hour (UTC). */ |
| 54 | readonly hour: string; |
| 55 | readonly count: number; |
| 56 | readonly errCount: number; |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * 24-hour invocation timeseries bucketed by hour. Drives the project |
| 61 | * overview's sparkline. Fills missing hours with zeros so the chart |
| 62 | * always has 24 points regardless of how sparse the traffic is. |
| 63 | */ |
| 64 | export async function getHourlyInvocations( |
| 65 | projectId: string, |
| 66 | ): Promise<readonly HourlyInvocations[]> { |
| 67 | const db = getDb(); |
| 68 | // ISO string, not Date: postgres.js can't serialize a raw Date param in |
| 69 | // sql`` templates under Bun ("string argument … Received an instance of |
| 70 | // Date") — the ::timestamptz cast makes the string unambiguous. |
| 71 | const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); |
| 72 | // generate_series fills missing hours so the chart is a stable 24 points. |
| 73 | const rows = (await db.execute(sql` |
| 74 | WITH hours AS ( |
| 75 | SELECT generate_series( |
| 76 | date_trunc('hour', ${since}::timestamptz), |
| 77 | date_trunc('hour', now()), |
| 78 | interval '1 hour' |
| 79 | ) AS hour |
| 80 | ), |
| 81 | logs AS ( |
| 82 | SELECT |
| 83 | date_trunc('hour', created_at) AS hour, |
| 84 | count(*)::int AS count, |
| 85 | count(*) FILTER (WHERE status = 'err')::int AS err_count |
| 86 | FROM function_logs |
| 87 | WHERE project_id = ${projectId} |
| 88 | AND created_at >= ${since}::timestamptz |
| 89 | GROUP BY 1 |
| 90 | ) |
| 91 | SELECT |
| 92 | hours.hour AS hour, |
| 93 | coalesce(logs.count, 0) AS count, |
| 94 | coalesce(logs.err_count, 0) AS err_count |
| 95 | FROM hours |
| 96 | LEFT JOIN logs ON logs.hour = hours.hour |
| 97 | ORDER BY hours.hour |
| 98 | `)) as Array<{ hour: string | Date; count: number | string; err_count: number | string }>; |
| 99 | return rows.map((r) => ({ |
| 100 | hour: (r.hour instanceof Date ? r.hour : new Date(r.hour)).toISOString(), |
| 101 | count: Number(r.count) || 0, |
| 102 | errCount: Number(r.err_count) || 0, |
| 103 | })); |
| 104 | } |
| 105 | |
| 106 | export interface FunctionStats { |
| 107 | readonly count: number; |
| 108 | readonly errCount: number; |
| 109 | readonly p50Ms: number; |
| 110 | readonly p99Ms: number; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Per-function aggregates over the last N hours. Drives the per-function |
| 115 | * stats badge on the functions tab. Uses percentile_cont — postgres-only |
| 116 | * but the function_logs table is always in postgres so that's fine. |
| 117 | * |
| 118 | * durationMs is stored as varchar (legacy choice from the runtime payload |
| 119 | * shape) — CAST to numeric for the aggregation. |
| 120 | */ |
| 121 | export async function getFunctionStats( |
| 122 | projectId: string, |
| 123 | functionName: string, |
| 124 | sinceHours = 24, |
| 125 | ): Promise<FunctionStats> { |
| 126 | const db = getDb(); |
| 127 | // ISO string, not Date — same driver limitation as getHourlyInvocations. |
| 128 | const since = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString(); |
| 129 | const rows = (await db.execute(sql` |
| 130 | SELECT |
| 131 | count(*)::int AS count, |
| 132 | count(*) FILTER (WHERE status = 'err')::int AS err_count, |
| 133 | coalesce(percentile_cont(0.5) WITHIN GROUP (ORDER BY (duration_ms::numeric)), 0) AS p50, |
| 134 | coalesce(percentile_cont(0.99) WITHIN GROUP (ORDER BY (duration_ms::numeric)), 0) AS p99 |
| 135 | FROM function_logs |
| 136 | WHERE project_id = ${projectId} |
| 137 | AND function_name = ${functionName} |
| 138 | AND created_at >= ${since}::timestamptz |
| 139 | `)) as Array<{ |
| 140 | count: number | string; |
| 141 | err_count: number | string; |
| 142 | p50: number | string; |
| 143 | p99: number | string; |
| 144 | }>; |
| 145 | const row = rows[0]; |
| 146 | if (!row) { |
| 147 | return { count: 0, errCount: 0, p50Ms: 0, p99Ms: 0 }; |
| 148 | } |
| 149 | return { |
| 150 | count: Number(row.count) || 0, |
| 151 | errCount: Number(row.err_count) || 0, |
| 152 | p50Ms: Math.round(Number(row.p50) || 0), |
| 153 | p99Ms: Math.round(Number(row.p99) || 0), |
| 154 | }; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Distinct function names actually called in this project. Drives the |
| 159 | * function picker on the logs page so the user can narrow to one |
| 160 | * function without typing the name from memory. |
| 161 | */ |
| 162 | export async function listFunctionNames(projectId: string): Promise<string[]> { |
| 163 | const db = getDb(); |
| 164 | const rows = await db |
| 165 | .selectDistinct({ name: functionLogs.functionName }) |
| 166 | .from(functionLogs) |
| 167 | .where(eq(functionLogs.projectId, projectId)) |
| 168 | .orderBy(functionLogs.functionName); |
| 169 | return rows.map((r) => r.name); |
| 170 | } |