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

465 lines
16 KiB
TypeScript

import { createHash } from 'node:crypto';
import { db } from '../db/client.js';
import { listBoardPosts, createBoardPost } from './board-service.js';
import { listErrorLogs } from './error-log-service.js';
import { ensurePlanTable, PLAN_TABLE } from './plan-service.js';
const DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT = 6;
const ERROR_LOG_BOARD_POST_MARKER_PREFIX = '<!-- error-log-plan-work-id:';
type ErrorLogCandidate = {
fingerprint: string;
workId: string;
source: string;
sourceLabel: string | null;
errorType: string;
errorName: string | null;
errorMessage: string;
requestPath: string | null;
requestPathGroup: string;
requestPaths: string[];
statusCode: number | null;
count: number;
firstCreatedAt: unknown;
lastCreatedAt: unknown;
sampleLogId: number | null;
sampleLogIds: number[];
errorNames: string[];
representativeMessages: string[];
groupedScopes?: string[];
groupedCandidateCount?: number;
};
type ErrorLogRegistrationSkip = {
workId: string;
reason: string;
boardPostId?: number;
planId?: number;
};
type ErrorLogBoardPostRegistration = {
postId: number;
title: string;
workId: string;
count: number;
};
function normalizeDateBoundary(value: unknown) {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
}
function formatIsoTimestamp(value: unknown) {
const date = normalizeDateBoundary(value);
return date ? date.toISOString() : String(value ?? '');
}
function normalizeRequestPathGroup(requestPath: unknown) {
const normalized = String(requestPath ?? '')
.trim()
.toLowerCase()
.replace(/\?.*$/u, '')
.replace(/\/\d+(?=\/|$)/gu, '/:id')
.replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id');
if (!normalized) {
return 'unknown';
}
const segments = normalized
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.slice(0, 3);
return segments.length > 0 ? `/${segments.join('/')}` : normalized;
}
function buildErrorLogPlanFingerprint(log: Record<string, unknown>) {
return createHash('sha1')
.update(
[
String(log.source ?? '').trim().toLowerCase(),
String(log.errorType ?? '').trim().toLowerCase(),
log.statusCode == null ? '' : String(log.statusCode),
].join('||'),
)
.digest('hex')
.slice(0, 12);
}
function buildErrorLogPlanWorkId(log: Record<string, unknown>) {
return `error-fix-${buildErrorLogPlanFingerprint(log)}`;
}
function buildErrorLogPlanCandidates(logs: Array<Record<string, unknown>>) {
const grouped = new Map<string, any>();
for (const log of logs) {
const fingerprint = createHash('sha1')
.update(
[
String(log.source ?? '').trim().toLowerCase(),
String(log.errorType ?? '').trim().toLowerCase(),
String(log.errorName ?? '').trim().toLowerCase(),
log.statusCode == null ? '' : String(log.statusCode),
normalizeRequestPathGroup(log.requestPath),
].join('||'),
)
.digest('hex')
.slice(0, 12);
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0;
const existing = grouped.get(fingerprint);
const requestPathGroup = normalizeRequestPathGroup(log.requestPath);
const errorMessage = String(log.errorMessage ?? '').trim();
if (!existing) {
grouped.set(fingerprint, {
sample: log,
count: 1,
firstCreatedAt: log.createdAt,
firstTimeMs: createdAtMs,
lastCreatedAt: log.createdAt,
lastTimeMs: createdAtMs,
requestPathGroup,
requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(),
errorMessages: errorMessage ? new Set([errorMessage]) : new Set(),
errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(),
sampleLogIds: log.id != null ? [Number(log.id)] : [],
});
continue;
}
existing.count += 1;
if (log.requestPath) {
existing.requestPaths.add(String(log.requestPath).trim());
}
if (errorMessage) {
existing.errorMessages.add(errorMessage);
}
if (log.errorName) {
existing.errorNames.add(String(log.errorName).trim());
}
if (log.id != null && existing.sampleLogIds.length < 5) {
existing.sampleLogIds.push(Number(log.id));
}
if (createdAtMs <= existing.firstTimeMs) {
existing.firstCreatedAt = log.createdAt;
existing.firstTimeMs = createdAtMs;
}
if (createdAtMs >= existing.lastTimeMs) {
existing.sample = log;
existing.lastCreatedAt = log.createdAt;
existing.lastTimeMs = createdAtMs;
}
}
return [...grouped.entries()]
.map(([fingerprint, entry]) => ({
fingerprint,
workId: buildErrorLogPlanWorkId(entry.sample),
source: String(entry.sample.source ?? ''),
sourceLabel: entry.sample.sourceLabel ? String(entry.sample.sourceLabel) : null,
errorType: String(entry.sample.errorType ?? ''),
errorName: entry.sample.errorName ? String(entry.sample.errorName) : null,
errorMessage: String(entry.sample.errorMessage ?? ''),
requestPath: entry.sample.requestPath ? String(entry.sample.requestPath) : null,
requestPathGroup: entry.requestPathGroup,
requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5),
statusCode: entry.sample.statusCode == null ? null : Number(entry.sample.statusCode),
count: entry.count,
firstCreatedAt: entry.firstCreatedAt,
lastCreatedAt: entry.lastCreatedAt,
sampleLogId: entry.sample.id == null ? null : Number(entry.sample.id),
sampleLogIds: entry.sampleLogIds,
errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5),
representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5),
}))
.sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0;
const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0;
if (rightLastTime !== leftLastTime) {
return rightLastTime - leftLastTime;
}
return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0);
});
}
function mergeErrorLogPlanCandidateBucket(bucket: ErrorLogCandidate[], bucketIndex: number): ErrorLogCandidate {
const sortedBucket = [...bucket].sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
});
const representative = sortedBucket[0];
const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort();
const mergedFingerprint = createHash('sha1')
.update(uniqueFingerprints.join('||'))
.digest('hex')
.slice(0, 12);
const firstCreatedAt = sortedBucket
.map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY)
.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
const lastCreatedAt = sortedBucket
.map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0)
.reduce((max, value) => Math.max(max, value), 0);
const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8);
const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8);
const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8);
const groupedScopes = sortedBucket
.slice(0, 8)
.map((candidate) => {
const parts = [candidate.sourceLabel || candidate.source, candidate.errorType];
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
parts.push(candidate.requestPathGroup);
}
return parts.filter(Boolean).join(' / ');
})
.filter(Boolean);
return {
fingerprint: mergedFingerprint,
workId: `error-fix-bundle-${mergedFingerprint}`,
source: representative.source,
sourceLabel: representative.sourceLabel,
errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType,
errorName: representative.errorName,
errorMessage: representative.errorMessage,
requestPath: representative.requestPath,
requestPathGroup: representative.requestPathGroup,
requestPaths,
statusCode: representative.statusCode,
count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0),
firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt,
lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt,
sampleLogId: representative.sampleLogId,
sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8),
errorNames,
representativeMessages,
groupedScopes,
groupedCandidateCount: sortedBucket.length,
};
}
function coalesceErrorLogPlanCandidates(candidates: ErrorLogCandidate[], maxGroups = DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT) {
const sortedCandidates = [...candidates].sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
});
if (sortedCandidates.length <= maxGroups) {
return sortedCandidates;
}
const bucketSize = Math.ceil(sortedCandidates.length / maxGroups);
const merged: ErrorLogCandidate[] = [];
for (let index = 0; index < sortedCandidates.length; index += bucketSize) {
const bucket = sortedCandidates.slice(index, index + bucketSize);
if (bucket.length === 0) {
continue;
}
merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1));
}
return merged.slice(0, maxGroups);
}
function filterLogsWithinRange(logs: Array<Record<string, unknown>>, rangeStart: Date, rangeEnd: Date) {
const startTime = rangeStart.getTime();
const endTime = rangeEnd.getTime();
return logs.filter((log) => {
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime();
return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime;
});
}
function formatErrorLogPlanNote(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
const lines = [
`조회 구간: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`,
`발생 건수: ${candidate.count}`,
`최근 발생: ${formatIsoTimestamp(candidate.lastCreatedAt)}`,
`최초 발생: ${formatIsoTimestamp(candidate.firstCreatedAt)}`,
`에러 유형: ${candidate.errorType}`,
];
if (candidate.errorName) {
lines.push(`에러 이름: ${candidate.errorName}`);
}
if (candidate.sourceLabel || candidate.source) {
lines.push(`발생 위치: ${candidate.sourceLabel || candidate.source}`);
}
if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) {
lines.push(`묶인 에러 그룹: ${candidate.groupedCandidateCount}`);
}
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
lines.push(`주요 경로 그룹: ${candidate.requestPathGroup}`);
}
if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) {
lines.push(`대표 경로: ${candidate.requestPaths.join(', ')}`);
}
if (candidate.statusCode != null) {
lines.push(`상태 코드: ${candidate.statusCode}`);
}
if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) {
lines.push('대표 메시지:');
lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`));
} else {
lines.push(`대표 메시지: ${String(candidate.errorMessage ?? '').trim()}`);
}
if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) {
lines.push(`대표 로그 ID: ${candidate.sampleLogIds.join(', ')}`);
} else {
lines.push(`대표 로그 ID: ${candidate.sampleLogId}`);
}
if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) {
lines.push('묶인 에러 범위:');
lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`));
}
lines.push('');
lines.push('처리 요청:');
lines.push('1. 재현 경로와 영향 범위를 확인합니다.');
lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.');
lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.');
return lines.join('\n');
}
function buildBoardPostMarker(workId: string) {
return `${ERROR_LOG_BOARD_POST_MARKER_PREFIX}${workId} -->`;
}
function buildErrorLogBoardPostTitle(candidate: ErrorLogCandidate) {
const scope = candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown'
? ` ${candidate.requestPathGroup}`
: '';
const title = `에러로그 조치 계획: ${candidate.errorType}${scope}`.replace(/\s+/g, ' ').trim();
return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title;
}
function buildErrorLogBoardPostContent(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
const detailNote = formatErrorLogPlanNote(candidate, rangeStart, rangeEnd);
return [
buildBoardPostMarker(candidate.workId),
`# ${buildErrorLogBoardPostTitle(candidate)}`,
'',
detailNote,
].join('\n');
}
export async function registerErrorLogBoardPosts(args?: {
rangeStart?: Date;
rangeEnd?: Date;
maxGroups?: number;
}) {
await ensurePlanTable();
const rangeEnd = args?.rangeEnd ?? new Date();
const rangeStart = args?.rangeStart ?? new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000);
const maxGroups = args?.maxGroups ?? DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT;
const [errorLogs, existingBoardPosts] = await Promise.all([
listErrorLogs(200),
listBoardPosts(),
]);
const recentLogs = filterLogsWithinRange(errorLogs as Array<Record<string, unknown>>, rangeStart, rangeEnd);
const rawCandidates = buildErrorLogPlanCandidates(recentLogs as Array<Record<string, unknown>>);
const candidates = coalesceErrorLogPlanCandidates(rawCandidates, maxGroups);
const createdPosts: ErrorLogBoardPostRegistration[] = [];
const skippedPosts: ErrorLogRegistrationSkip[] = [];
for (const candidate of candidates) {
const marker = buildBoardPostMarker(candidate.workId);
const existingBoardPost = existingBoardPosts.find((post) => String(post.content ?? '').includes(marker));
if (existingBoardPost) {
skippedPosts.push({
workId: candidate.workId,
boardPostId: existingBoardPost.id,
reason: `기존 게시글 #${existingBoardPost.id}가 있습니다.`,
});
continue;
}
const latestOpenPlan = await db(PLAN_TABLE)
.select(['id', 'status'])
.where({ work_id: candidate.workId })
.whereNot({ status: '완료' as never })
.orderBy('id', 'desc')
.first();
if (latestOpenPlan) {
skippedPosts.push({
workId: candidate.workId,
planId: Number(latestOpenPlan.id),
reason: `기존 미완료 Plan #${latestOpenPlan.id}가 있습니다.`,
});
continue;
}
const createdPost = await createBoardPost({
title: buildErrorLogBoardPostTitle(candidate),
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [],
automationType: 'none',
automationContextIds: [],
requestExecutionMode: 'all_at_once',
requestItems: [],
});
createdPosts.push({
postId: createdPost.id,
title: createdPost.title,
workId: candidate.workId,
count: candidate.count,
});
}
return {
rangeStart,
rangeEnd,
recentLogs,
rawCandidates,
candidates,
createdPosts,
skippedPosts,
};
}