465 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|