diff --git a/.env.example b/.env.example index 46be9b1..c1431fc 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ ENABLE_REQUEST_LOGGING=true # Creator and indexer tuning INDEXER_JITTER_FACTOR=0.1 BACKGROUND_JOB_LOCK_TTL_MS=300000 +SLOW_QUERY_THRESHOLD_MS=500 CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS=500 INDEXER_CURSOR_STALE_AGE_WARNING_MS=300000 INDEXER_HEARTBEAT_STALE_THRESHOLD_MS=300000 diff --git a/src/config.schema.ts b/src/config.schema.ts index 5f32651..d277434 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -88,6 +88,7 @@ export const envSchema = z INDEXER_JITTER_FACTOR: z.coerce.number().min(0).max(1).default(0.1), BACKGROUND_JOB_LOCK_TTL_MS: z.coerce.number().int().positive().default(300000), + SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500), INDEXER_CURSOR_STALE_AGE_WARNING_MS: z.coerce.number().int().positive().default(300000), INDEXER_HEARTBEAT_STALE_THRESHOLD_MS: z.coerce.number().positive().default(300000), diff --git a/src/utils/env-boolean.utils.test.ts b/src/utils/env-boolean.utils.test.ts new file mode 100644 index 0000000..532e735 --- /dev/null +++ b/src/utils/env-boolean.utils.test.ts @@ -0,0 +1,82 @@ +import { normalizeEnvBoolean, EnvBooleanParseError } from './env-boolean.utils'; + +describe('normalizeEnvBoolean', () => { + describe('true variants', () => { + it.each([ + ['true'], + ['True'], + ['TRUE'], + ['1'], + ['yes'], + ['Yes'], + ['YES'], + ])('returns true for "%s"', (value) => { + expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(true); + }); + + it('trims surrounding whitespace before parsing', () => { + expect(normalizeEnvBoolean('MY_FLAG', ' true ')).toBe(true); + }); + }); + + describe('false variants', () => { + it.each([ + ['false'], + ['False'], + ['FALSE'], + ['0'], + ['no'], + ['No'], + ['NO'], + ])('returns false for "%s"', (value) => { + expect(normalizeEnvBoolean('MY_FLAG', value)).toBe(false); + }); + + it('trims surrounding whitespace before parsing', () => { + expect(normalizeEnvBoolean('MY_FLAG', ' false ')).toBe(false); + }); + }); + + describe('unrecognized values', () => { + it.each([ + ['maybe'], + ['2'], + ['on'], + ['off'], + ['enabled'], + ['disabled'], + [''], + ['null'], + ['undefined'], + ])('throws EnvBooleanParseError for "%s"', (value) => { + expect(() => normalizeEnvBoolean('MY_FLAG', value)).toThrow( + EnvBooleanParseError + ); + }); + + it('includes the env var name in the error message', () => { + expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'maybe')).toThrow( + /FEATURE_FLAG/ + ); + }); + + it('includes the raw value in the error message', () => { + expect(() => normalizeEnvBoolean('FEATURE_FLAG', 'banana')).toThrow( + /banana/ + ); + }); + + it('exposes varName and rawValue on the thrown error', () => { + let caught: unknown; + try { + normalizeEnvBoolean('MY_VAR', 'oops'); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(EnvBooleanParseError); + const error = caught as EnvBooleanParseError; + expect(error.varName).toBe('MY_VAR'); + expect(error.rawValue).toBe('oops'); + }); + }); +}); diff --git a/src/utils/env-boolean.utils.ts b/src/utils/env-boolean.utils.ts new file mode 100644 index 0000000..c0debbb --- /dev/null +++ b/src/utils/env-boolean.utils.ts @@ -0,0 +1,46 @@ +/** + * Helper for normalizing boolean string values from environment configuration. + * + * Environment variables for boolean flags may arrive as "true", "false", "1", + * "0", "yes", or "no" depending on the deployment environment. This helper + * converts those variants into a real boolean and rejects everything else. + * + * Accepted true variants: "true" | "1" | "yes" + * Accepted false variants: "false" | "0" | "no" + * Unrecognized values throw an EnvBooleanParseError. + */ + +const TRUE_VALUES = new Set(['true', '1', 'yes']); +const FALSE_VALUES = new Set(['false', '0', 'no']); + +export class EnvBooleanParseError extends Error { + public readonly varName: string; + public readonly rawValue: string; + + constructor(varName: string, rawValue: string) { + super( + `Cannot parse env var "${varName}" as boolean: received "${rawValue}". ` + + `Accepted values: "true", "false", "1", "0", "yes", "no".` + ); + this.name = 'EnvBooleanParseError'; + this.varName = varName; + this.rawValue = rawValue; + } +} + +/** + * Normalize a raw environment variable string value to a boolean. + * + * @param varName - The env var name, used in error messages + * @param value - The raw string value read from the environment + * @returns `true` or `false` + * @throws {EnvBooleanParseError} when the value is not a recognized boolean string + */ +export function normalizeEnvBoolean(varName: string, value: string): boolean { + const normalized = value.trim().toLowerCase(); + + if (TRUE_VALUES.has(normalized)) return true; + if (FALSE_VALUES.has(normalized)) return false; + + throw new EnvBooleanParseError(varName, value); +} diff --git a/src/utils/prisma.utils.ts b/src/utils/prisma.utils.ts index c649d0c..ae42602 100644 --- a/src/utils/prisma.utils.ts +++ b/src/utils/prisma.utils.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { createHash } from 'crypto'; import { envConfig } from '../config'; import { requestContextStorage } from './als.utils'; import { logger } from './logger.utils'; @@ -8,6 +9,50 @@ declare global { var prisma: any | undefined; } +/** + * Replace all primitive leaf values in a Prisma args object with '?' so the + * resulting structure identifies the query pattern without exposing any values. + */ +function normalizeArgsForFingerprint(value: unknown, depth = 0): unknown { + if (depth > 8) return '?'; + if (value === null || value === undefined) return value; + if (Array.isArray(value)) { + return value.map((item) => normalizeArgsForFingerprint(item, depth + 1)); + } + if (typeof value === 'object') { + const sorted = Object.keys(value as object).sort(); + const result: Record = {}; + for (const key of sorted) { + result[key] = normalizeArgsForFingerprint( + (value as Record)[key], + depth + 1 + ); + } + return result; + } + return '?'; +} + +/** + * Build a short deterministic hash that identifies the query pattern (model, + * operation, and arg structure) without including any parameter values. + */ +function buildQueryFingerprint( + model: string | undefined, + operation: string, + args: unknown +): string { + const normalized = { + model: model ?? 'unknown', + operation, + args: normalizeArgsForFingerprint(args), + }; + return createHash('sha256') + .update(JSON.stringify(normalized)) + .digest('hex') + .slice(0, 16); +} + const basePrisma = new PrismaClient({ log: envConfig.MODE === 'development' @@ -16,16 +61,20 @@ const basePrisma = new PrismaClient({ datasourceUrl: envConfig.DATABASE_URL, }); -// Extend Prisma with query timeout +// Extend Prisma with query timeout and slow-query detection export const prisma = basePrisma.$extends({ query: { $allOperations({ operation, model, args, query }) { const timeoutMs = envConfig.DB_QUERY_TIMEOUT_MS; + const slowThresholdMs = envConfig.SLOW_QUERY_THRESHOLD_MS; const context = requestContextStorage.getStore(); let timeoutId: NodeJS.Timeout; + let timedOut = false; + const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { + timedOut = true; const logContext = { type: 'database_timeout', operation, @@ -40,10 +89,29 @@ export const prisma = basePrisma.$extends({ }, timeoutMs); }); - return Promise.race([ - query(args).finally(() => clearTimeout(timeoutId)), - timeoutPromise, - ]); + const start = Date.now(); + const queryPromise = query(args).finally(() => { + clearTimeout(timeoutId); + if (!timedOut) { + const elapsedMs = Date.now() - start; + if (elapsedMs > slowThresholdMs) { + logger.warn( + { + type: 'slow_query', + model, + operation, + fingerprint: buildQueryFingerprint(model, operation, args), + elapsedMs, + thresholdMs: slowThresholdMs, + requestId: context?.requestId, + }, + 'Slow database query detected' + ); + } + } + }); + + return Promise.race([queryPromise, timeoutPromise]); }, }, });