function-logs.ts170 lines · main
1import { and, desc, eq, lt, sql } from 'drizzle-orm';
2
3import { getDb } from '../db/client.js';
4import { 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 */
15export 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
26export 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
52export 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 */
64export 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
106export 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 */
121export 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 */
162export 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}