From 028e40a1810fc5f2778d9c2ab44122ace40d5d74 Mon Sep 17 00:00:00 2001 From: Clement Denis Date: Mon, 30 Mar 2026 12:14:16 +0200 Subject: [PATCH] feat: add a route to retrieve query metrics --- api/dev.ts | 25 ++++++- api/env.ts | 12 ++++ api/router.ts | 24 ++++--- db/mod.ts | 165 +++++++++++++++++++++++----------------------- types/db.d.ts | 9 ++- types/router.d.ts | 6 ++ 6 files changed, 141 insertions(+), 100 deletions(-) diff --git a/api/dev.ts b/api/dev.ts index d13ed87..1b369a2 100644 --- a/api/dev.ts +++ b/api/dev.ts @@ -2,8 +2,8 @@ import { APP_ENV, DEVTOOL_ACCESS_TOKEN } from './env.ts' import { respond } from './response.ts' import type { RequestContext } from '@01edu/types/context' import { route } from './router.ts' -import { ARR, OBJ, optional, STR } from './validator.ts' -import type { Sql } from '@01edu/types/db' +import { ARR, NUM, OBJ, optional, STR } from './validator.ts' +import type { Metric, Sql } from '@01edu/types/db' /** * Authorizes access to developer routes. @@ -58,3 +58,24 @@ export const createSqlDevRoute = (sql?: Sql) => { description: 'Execute an SQL query', }) } + +/** + * Creates a route handler that exposes collected query metrics. + * + * @returns A route handler configuration. + */ +export const createQueryMetricsDevRoute = (metrics: Metric[]) => + route({ + authorize: authorizeDevAccess, + fn: () => metrics, + output: ARR( + OBJ({ + query: STR('The SQL query text'), + count: NUM('How many times the query has run'), + duration: NUM('Total time spent running the query in milliseconds'), + explain: STR('The SQL explain text'), + }), + 'Collected query metrics', + ), + description: 'List collected SQL query metrics', + }) diff --git a/api/env.ts b/api/env.ts index 4208392..85cda06 100644 --- a/api/env.ts +++ b/api/env.ts @@ -27,6 +27,11 @@ export const ENV: EnvGetter = (key, fallback) => { throw Error(`${key}: field required in the env`) } +const truthy = (value: string): boolean => { + const v = value.toLowerCase() + return v === 'true' || v === '1' || v === 'yes' +} + /** * The possible application environments. */ @@ -100,6 +105,13 @@ export const DEVTOOL_URL: string = ENV('DEVTOOL_URL', '') */ export const DEVTOOL_ACCESS_TOKEN: string = ENV('DEVTOOL_ACCESS_TOKEN', '') +/** + * Disable query debug instrumentation when set in the environment. + */ +export const DISABLE_QUERY_METRICS: boolean = truthy( + ENV('DISABLE_QUERY_METRICS', ''), +) + const forAppEnv = (env: AppEnvironments) => (key: string, fallback?: string): string => { const value = Deno.env.get(key) diff --git a/api/router.ts b/api/router.ts index 7043519..f7d4d1c 100644 --- a/api/router.ts +++ b/api/router.ts @@ -22,8 +22,8 @@ import type { } from '@01edu/types/router' import type { Log } from './log.ts' import { respond, ResponseError } from './response.ts' -import type { Sql } from '@01edu/types/db' -import { createSqlDevRoute } from './dev.ts' +import type { Metric, Sql } from '@01edu/types/db' +import { createQueryMetricsDevRoute, createSqlDevRoute } from './dev.ts' import { createDocRoute } from './doc.ts' import { createHealthRoute } from './health.ts' @@ -34,6 +34,7 @@ export type RouterOptions = { log: Log sql?: Sql sensitiveKeys?: string[] + metrics?: Metric[] } /** @@ -99,6 +100,7 @@ const sensitiveData = ( * import { makeRouter, route } from '@01edu/router'; * import { logger } from '@01edu/log'; * import { STR } from '@01edu/validator'; + * import { metrics } from '@01edu/db'; * * const log = await logger({}); * const routes = { @@ -109,21 +111,13 @@ const sensitiveData = ( * }), * }; * - * const router = makeRouter(routes, { log }); + * const router = makeRouter(routes, { log, metrics }); * ``` */ +const pass = ['password', 'confPassword', 'currentPassword', 'newPassword'] export const makeRouter = ( defs: T, - { - log, - sql, - sensitiveKeys = [ - 'password', - 'confPassword', - 'currentPassword', - 'newPassword', - ], - }: RouterOptions, + { log, sql, metrics, sensitiveKeys = pass }: RouterOptions, ): (ctx: RequestContext) => Awaitable => { const routeMaps: Record = Object.create(null) @@ -131,6 +125,10 @@ export const makeRouter = ( defs['POST/api/execute-sql'] = createSqlDevRoute(sql) } + if (!defs['GET/api/metrics-sql'] && metrics) { + defs['GET/api/metrics-sql'] = createQueryMetricsDevRoute(metrics) + } + if (!defs['GET/api/doc']) { defs['GET/api/doc'] = createDocRoute(defs) } diff --git a/db/mod.ts b/db/mod.ts index 903402c..9c738f8 100644 --- a/db/mod.ts +++ b/db/mod.ts @@ -7,16 +7,11 @@ */ import { assertEquals } from '@std/assert/equals' -import { - type BindParameters, - type BindValue, - Database, - type RestBindParameters, -} from '@db/sqlite' +import { type BindParameters, type BindValue, Database } from '@db/sqlite' import type { Expand, MatchKeys, UnionToIntersection } from '@01edu/types' -import type { Sql } from '@01edu/types/db' +import type { Metric, Sql } from '@01edu/types/db' import { respond } from '@01edu/api/response' -import { APP_ENV, ENV } from '@01edu/api/env' +import { APP_ENV, DISABLE_QUERY_METRICS, ENV } from '@01edu/api/env' const dbPath = ENV('DATABASE_PATH', ':memory:') /** @@ -99,6 +94,16 @@ type SelectReturnType, K extends keyof T> = { [Column in K]: DBTypes[T[Column]['type']] } +type SqlResult< + T extends { [k in string]: unknown } | undefined, + P extends BindValue | BindParameters | undefined, +> = { + get: (params?: P) => T | undefined + all: (params?: P) => T[] + value: (params?: P) => T[keyof T][] | undefined + run: (params?: P) => number +} + type FlattenProperties = Expand< UnionToIntersection< { @@ -238,10 +243,7 @@ export type TableAPI = { sql: < K extends keyof FlattenProperties

, T extends BindParameters | BindValue | undefined, - >(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { - get: (params?: T, ...args: RestBindParameters) => Row | undefined - all: (params?: T, ...args: RestBindParameters) => Row[] - } + >(sqlArr: TemplateStringsArray, ...vars: unknown[]) => SqlResult, T> } /** @@ -316,94 +318,71 @@ export const createTable = ( 'Database expected schema and current schema missmatch, maybe you need a migration ?', ) - const insertStmt = db.prepare(` - INSERT INTO ${name} (${keys.join(', ')}) - VALUES (${keys.map((k) => `:${k}`).join(', ')}) - `) - - const insert = (entries: InferInsertType

) => { - insertStmt.run(entries) - return db.lastInsertRowId - } - // Add dynamic update functionality const primaryKey = Object.keys(properties) .find((k: keyof P) => properties[k].primary) + const insertStmt = db.prepare(` + INSERT INTO ${name} (${keys.join(', ')}) + VALUES (${keys.map((k) => `:${k}`).join(', ')}) + `) const updateStmt = db.prepare(` UPDATE ${name} SET ${keys.map((k) => `${k} = COALESCE(:${k}, ${k})`).join(', ')} WHERE ${primaryKey} = :${primaryKey} `) - - const update = ( - entries: Expand< - & { [K in PrimaryKeys

]: DBTypes[P[K]['type']] } - & Partial> - >, - ) => { - // Make sure the primary key field exists in the entries - if (!entries[primaryKey as keyof typeof entries]) { - throw Error(`Primary key ${primaryKey} must be provided for update`) - } - return updateStmt.run(entries) - } - const existsStmt = db.prepare(` SELECT EXISTS (SELECT 1 FROM ${name} WHERE ${primaryKey} = ?) `) - - const notFound = { message: `${name} not found` } - const exists = (id: number) => existsStmt.value(id)?.[0] === 1 - const assert = (id: number | undefined) => { - if (id && exists(id)) return - throw new respond.NotFoundError(notFound) - } - const getByIdStmt = db.prepare( `SELECT * FROM ${name} WHERE ${primaryKey} = ? LIMIT 1`.trim(), ) - - const get = (id: number): Row | undefined => - getByIdStmt.get(id) - - const require = (id: number | undefined) => { - const match = id && getByIdStmt.get(id) - if (!match) throw new respond.NotFoundError(notFound) - return match as Row - } - - const sql = < - K extends keyof FlatProps, - T extends BindParameters | BindValue | undefined, - >(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { - const query = String.raw(sqlArr, ...vars) - const stmt = db.prepare(query) - return { - get: stmt.get.bind(stmt) as ( - params?: T, - ...args: RestBindParameters - ) => Row | undefined, - all: stmt.all.bind(stmt) as ( - params?: T, - ...args: RestBindParameters - ) => Row[], - } - } - + const notFound = { message: `${name} not found` } + const exists = (id: number) => existsStmt.value(id)?.[0] === 1 return { name, - insert, - update, + insert: (entries: InferInsertType

) => { + insertStmt.run(entries) + return db.lastInsertRowId + }, + update: ( + entries: Expand< + & { [K in PrimaryKeys

]: DBTypes[P[K]['type']] } + & Partial> + >, + ) => { + // Make sure the primary key field exists in the entries + if (!entries[primaryKey as keyof typeof entries]) { + throw Error(`Primary key ${primaryKey} must be provided for update`) + } + return updateStmt.run(entries) + }, exists, - get, - require, - assert, - sql, + get: (id: number): Row | undefined => + getByIdStmt.get(id), + require: (id: number | undefined) => { + const match = id && getByIdStmt.get(id) + if (!match) throw new respond.NotFoundError(notFound) + return match as Row + }, + assert: (id: number | undefined) => { + if (id && exists(id)) return + throw new respond.NotFoundError(notFound) + }, + sql: (sqlArr, ...vars) => + sql, BindParameters | BindValue | undefined>( + sqlArr, + ...vars, + ), properties, } } +/** + * Query metrics collected while query debugging is enabled. + */ +export const metrics: Metric[] = [] + /** * A template literal tag for executing arbitrary SQL queries. * @@ -421,14 +400,32 @@ export const createTable = ( export const sql: Sql = < T extends { [k in string]: unknown } | undefined, P extends BindValue | BindParameters | undefined, ->(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { - const query = String.raw(sqlArr, ...vars) +>(sqlArr: TemplateStringsArray, ...vars: unknown[]): SqlResult => { + const query = String.raw(sqlArr, ...vars).trim() const stmt = db.prepare(query) + const boundStmt = { + get: (params?: P) => stmt.get(params) as T | undefined, + all: (params?: P) => stmt.all(params) as T[], + run: (params?: P) => stmt.run(params), + value: (params?: P) => stmt.value(params) as T[keyof T][] | undefined, + } + if (DISABLE_QUERY_METRICS || !query.startsWith('SELECT ')) return boundStmt + const explain = db.prepare(`EXPLAIN QUERY PLAN ${query}`).get()?.detail || '' + const m = { query, explain, count: 0, duration: 0 } + metrics.push(m) + + const withMetrics = (fn: (params?: P) => R) => (params?: P): R => { + const start = performance.now() + const result = fn(params) + m.count++ + m.duration += performance.now() - start + return result + } return { - get: stmt.get.bind(stmt) as (params?: P) => T | undefined, - all: stmt.all.bind(stmt) as (params?: P) => T[], - run: stmt.run.bind(stmt), - value: stmt.value.bind(stmt), + get: withMetrics(boundStmt.get), + all: withMetrics(boundStmt.all), + run: withMetrics(boundStmt.run), + value: withMetrics(boundStmt.value), } } diff --git a/types/db.d.ts b/types/db.d.ts index f66183a..b0c6f9c 100644 --- a/types/db.d.ts +++ b/types/db.d.ts @@ -10,6 +10,13 @@ export type Sql = < >(sqlArr: TemplateStringsArray, ...vars: unknown[]) => { get: (params?: P) => T | undefined all: (params?: P) => T[] - run: (params?: P) => void + run: (params?: P) => number value: (params?: P) => T[keyof T][] | undefined } + +export type Metric = { + query: string + count: number + duration: number + explain: string +} diff --git a/types/router.d.ts b/types/router.d.ts index 0570996..1ba172f 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -73,6 +73,12 @@ type ReservedRoutes = { * @deprecated */ 'POST/api/execute-sql'?: Handler + + /** + * ⚠️ WARNING: You are overriding the system SQL metrics route. + * @deprecated + */ + 'GET/api/metrics-sql'?: Handler } // deno-lint-ignore no-explicit-any