Files
ai-code-app/etc/servers/work-server/src/services/error-log-service.ts

165 lines
6.6 KiB
TypeScript

import { z } from 'zod';
import { db } from '../db/client.js';
export const ERROR_LOG_TABLE = 'error_logs';
export const ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
export const createErrorLogSchema = z.object({
source: z.enum(['server', 'client', 'automation']).default('server'),
sourceLabel: z.string().trim().max(80).optional().nullable(),
errorType: z.string().trim().min(1).max(120),
errorName: z.string().trim().max(255).optional().nullable(),
errorMessage: z.string().trim().min(1).max(10000),
detail: z.string().trim().max(50000).optional().nullable(),
stackTrace: z.string().trim().max(50000).optional().nullable(),
statusCode: z.number().int().min(100).max(599).optional().nullable(),
requestMethod: z.string().trim().max(10).optional().nullable(),
requestPath: z.string().trim().max(1000).optional().nullable(),
relatedPlanId: z.number().int().positive().optional().nullable(),
relatedWorkId: z.string().trim().max(120).optional().nullable(),
context: z.record(z.string(), z.unknown()).optional().nullable(),
});
export type ErrorLogPayload = z.infer<typeof createErrorLogSchema>;
async function ensureErrorLogTable() {
const hasTable = await db.schema.hasTable(ERROR_LOG_TABLE);
if (!hasTable) {
await db.schema.createTable(ERROR_LOG_TABLE, (table) => {
table.increments('id').primary();
table.string('source', 20).notNullable().defaultTo('server');
table.string('source_label', 80).nullable();
table.string('error_type', 120).notNullable();
table.string('error_name', 255).nullable();
table.text('error_message').notNullable();
table.text('detail').nullable();
table.text('stack_trace').nullable();
table.integer('status_code').nullable();
table.string('request_method', 10).nullable();
table.string('request_path', 1000).nullable();
table.integer('related_plan_id').nullable();
table.string('related_work_id', 120).nullable();
table.jsonb('context_json').nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['source', (table) => table.string('source', 20).notNullable().defaultTo('server')],
['source_label', (table) => table.string('source_label', 80).nullable()],
['error_type', (table) => table.string('error_type', 120).notNullable().defaultTo('unknown')],
['error_name', (table) => table.string('error_name', 255).nullable()],
['error_message', (table) => table.text('error_message').notNullable().defaultTo('')],
['detail', (table) => table.text('detail').nullable()],
['stack_trace', (table) => table.text('stack_trace').nullable()],
['status_code', (table) => table.integer('status_code').nullable()],
['request_method', (table) => table.string('request_method', 10).nullable()],
['request_path', (table) => table.string('request_path', 1000).nullable()],
['related_plan_id', (table) => table.integer('related_plan_id').nullable()],
['related_work_id', (table) => table.string('related_work_id', 120).nullable()],
['context_json', (table) => table.jsonb('context_json').nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(ERROR_LOG_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(ERROR_LOG_TABLE, (table) => {
createColumn(table);
});
}
}
}
function normalizePayload(payload: ErrorLogPayload) {
const parsedPayload = createErrorLogSchema.parse(payload);
const sourceLabel =
parsedPayload.sourceLabel ??
(parsedPayload.source === 'client'
? '프론트엔드'
: parsedPayload.source === 'automation'
? 'Plan 자동화'
: '워크서버 API');
return {
source: parsedPayload.source,
source_label: sourceLabel,
error_type: parsedPayload.errorType,
error_name: parsedPayload.errorName ?? null,
error_message: parsedPayload.errorMessage,
detail: parsedPayload.detail ?? null,
stack_trace: parsedPayload.stackTrace ?? null,
status_code: parsedPayload.statusCode ?? null,
request_method: parsedPayload.requestMethod ?? null,
request_path: parsedPayload.requestPath ?? null,
related_plan_id: parsedPayload.relatedPlanId ?? null,
related_work_id: parsedPayload.relatedWorkId ?? null,
context_json: parsedPayload.context ?? null,
};
}
function mapErrorLogRow(row: Record<string, unknown>) {
return {
id: Number(row.id),
source: String(row.source ?? 'server'),
sourceLabel: row.source_label ? String(row.source_label) : null,
errorType: String(row.error_type ?? ''),
errorName: row.error_name ? String(row.error_name) : null,
errorMessage: String(row.error_message ?? ''),
detail: row.detail ? String(row.detail) : null,
stackTrace: row.stack_trace ? String(row.stack_trace) : null,
statusCode: typeof row.status_code === 'number' ? row.status_code : row.status_code ? Number(row.status_code) : null,
requestMethod: row.request_method ? String(row.request_method) : null,
requestPath: row.request_path ? String(row.request_path) : null,
relatedPlanId:
typeof row.related_plan_id === 'number'
? row.related_plan_id
: row.related_plan_id
? Number(row.related_plan_id)
: null,
relatedWorkId: row.related_work_id ? String(row.related_work_id) : null,
context: row.context_json && typeof row.context_json === 'object' ? (row.context_json as Record<string, unknown>) : null,
createdAt: row.created_at,
};
}
export async function setupErrorLogTable() {
await ensureErrorLogTable();
return {
ok: true,
table: ERROR_LOG_TABLE,
};
}
export async function createErrorLog(payload: ErrorLogPayload) {
await ensureErrorLogTable();
const [row] = await db(ERROR_LOG_TABLE)
.insert({
...normalizePayload(payload),
created_at: db.fn.now(),
})
.returning('*');
return mapErrorLogRow(row);
}
export async function listErrorLogs(limit = 50) {
await ensureErrorLogTable();
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50;
const rows = await db(ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit);
return rows.map((row) => mapErrorLogRow(row));
}
export function hasErrorLogViewAccessToken(token: string | string[] | undefined) {
const normalizedToken = Array.isArray(token) ? token[0] : token;
return String(normalizedToken ?? '').trim() === ERROR_LOG_VIEW_TOKEN;
}