Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
82 changes: 82 additions & 0 deletions src/utils/env-boolean.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
46 changes: 46 additions & 0 deletions src/utils/env-boolean.utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
78 changes: 73 additions & 5 deletions src/utils/prisma.utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, unknown> = {};
for (const key of sorted) {
result[key] = normalizeArgsForFingerprint(
(value as Record<string, unknown>)[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'
Expand All @@ -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,
Expand All @@ -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]);
},
},
});
Expand Down
Loading