관리자 서버 진단에 lease 상태와 영속 장애 이력 추가

This commit is contained in:
2026-09-09 23:36:16 +00:00
parent 9c73085353
commit a00f46dfc8
19 changed files with 627 additions and 43 deletions
@@ -0,0 +1,51 @@
/** 관리자 장애 기록에서도 연결 URL과 인증값을 보존하지 않는다. */
export const sanitizeRuntimeErrorText = (text: string): string =>
text
.replace(/\b(?:https?|postgres(?:ql)?|rediss?):\/\/[^\s"'<>]+/gi, '[REDACTED_URL]')
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
.replace(
/((?:password|passwd|token|secret|authorization|cookie|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi,
'$1[REDACTED]'
)
.slice(0, 2000);
export const describeRuntimeError = (error: unknown): { code: string; message: string; frames: string[] } => ({
code: error instanceof Error ? error.name.slice(0, 100) : 'RuntimeError',
message: sanitizeRuntimeErrorText(error instanceof Error ? error.message : String(error)),
frames:
error instanceof Error
? (error.stack ?? '')
.split('\n')
.filter((line) => /^\s*at\s/.test(line))
.slice(0, 8)
.map(sanitizeRuntimeErrorText)
: [],
});
export interface ProfileRuntimeDiagnostics {
profileName: string;
checkedAt: string;
database: 'AVAILABLE' | 'UNAVAILABLE' | 'UNINITIALIZED';
processObservation: 'AVAILABLE' | 'UNAVAILABLE';
processes: Array<{ name: string; status: string; restartCount: number; exitCode: number | null }>;
lease: {
ownerId: string;
fencingEpoch: string;
heartbeatAt: string;
leaseUntil: string;
heartbeatAgeMs: number;
valid: boolean;
clockReady: boolean;
} | null;
clock: {
phase: string;
revision: string;
tick: string | null;
lastTurnTick: string | null;
year: number;
month: number;
wallAnchor: string | null;
recoveryStartWallAt: string | null;
recoveryEndTick: string | null;
} | null;
}
+1
View File
@@ -28,6 +28,7 @@ export * from './auth/accountIconProjection.js';
export * from './logging/formatLegacyLogHtml.js';
export * from './legacyArchive/ArchivedGeneralSnapshot.js';
export * from './gateway/profileStatus.js';
export * from './gateway/runtimeDiagnostics.js';
export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js';
export * from './webPush/types.js';
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { describeRuntimeError } from '../src/gateway/runtimeDiagnostics.js';
describe('runtime failure records', () => {
it('keeps the cause and frames while removing connection and authentication values', () => {
const error = new Error(
'database failed postgresql://admin:private@host/db password="hidden value" token=abc Bearer xyz'
);
error.stack = `${error.message}\n at flush (/srv/app/flush.ts:42:7)`;
const record = describeRuntimeError(error);
expect(record.code).toBe('Error');
expect(record.message).toContain('database failed');
for (const secret of ['private', 'hidden value', 'abc', 'xyz'])
expect(JSON.stringify(record)).not.toContain(secret);
expect(record.frames).toEqual([' at flush (/srv/app/flush.ts:42:7)']);
});
it('bounds untrusted messages and stack depth', () => {
const error = new Error('x'.repeat(4000));
error.stack = Array.from({ length: 30 }, () => ' at run (/app/run.ts:1:1)').join('\n');
expect(describeRuntimeError(error).message).toHaveLength(2000);
expect(describeRuntimeError(error).frames).toHaveLength(8);
});
});
+15 -5
View File
@@ -15,6 +15,7 @@ export interface PostgresConfig {
log?: PostgresLogOption[];
maxConnections?: number;
sessionTimezone?: 'UTC';
connectionTimeoutMillis?: number;
}
export interface PostgresPoolStats {
@@ -65,16 +66,18 @@ const buildSharedPoolKey = (
url: string,
schema: string | undefined,
maxConnections: number,
sessionTimezone: 'UTC' | undefined
): string => JSON.stringify([url, schema ?? '', maxConnections, sessionTimezone ?? '']);
sessionTimezone: 'UTC' | undefined,
connectionTimeoutMillis: number | undefined
): string => JSON.stringify([url, schema ?? '', maxConnections, sessionTimezone ?? '', connectionTimeoutMillis ?? 0]);
const acquireSharedPool = (
url: string,
schema: string | undefined,
maxConnections: number,
sessionTimezone: 'UTC' | undefined
sessionTimezone: 'UTC' | undefined,
connectionTimeoutMillis: number | undefined
): { entry: SharedPoolEntry; release: () => Promise<void> } => {
const key = buildSharedPoolKey(url, schema, maxConnections, sessionTimezone);
const key = buildSharedPoolKey(url, schema, maxConnections, sessionTimezone, connectionTimeoutMillis);
let entry = sharedPools.get(key);
if (!entry) {
const connectionOptions = [
@@ -86,6 +89,7 @@ const acquireSharedPool = (
const pool = new pg.Pool({
connectionString: url,
max: maxConnections,
...(connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis } : {}),
...(connectionOptions ? { options: connectionOptions } : {}),
});
entry = { pool, references: 0, maxConnections };
@@ -173,7 +177,13 @@ export const createPostgresConnector = <TClient>(
const schema =
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
const maxConnections = resolvePostgresPoolMax(config.maxConnections ?? process.env.POSTGRES_POOL_MAX);
const sharedPool = acquireSharedPool(config.url, schema, maxConnections, config.sessionTimezone);
const sharedPool = acquireSharedPool(
config.url,
schema,
maxConnections,
config.sessionTimezone,
config.connectionTimeoutMillis
);
const adapter = new PrismaPg(sharedPool.entry.pool, schema ? { schema } : undefined);
const prisma = createClient({
adapter,