웹 로그, DB 쿼리문과 결과 로그 출력
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export type ApiHandlerContext<P> = {
|
||||
params: Promise<P>;
|
||||
t0: number;
|
||||
};
|
||||
|
||||
export function withApiHandler<P = Record<string, never>>(
|
||||
ctx: string,
|
||||
handler: (req: Request, ctx: ApiHandlerContext<P>) => Promise<NextResponse>
|
||||
) {
|
||||
return async (
|
||||
request: Request,
|
||||
routeContext?: { params: Promise<P> }
|
||||
): Promise<NextResponse> => {
|
||||
const t0 = Date.now();
|
||||
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
logger.warn(ctx, "unauthorized");
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const handlerCtx: ApiHandlerContext<P> = {
|
||||
params: routeContext?.params ?? (Promise.resolve({}) as Promise<P>),
|
||||
t0,
|
||||
};
|
||||
|
||||
try {
|
||||
return await handler(request, handlerCtx);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
const level = error.statusCode >= 500 ? "error" : "warn";
|
||||
logger[level](ctx, error.message, {
|
||||
status: error.statusCode,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
logger.error(ctx, "unexpected error", {
|
||||
error: String(error),
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,202 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { format } from "sql-formatter";
|
||||
|
||||
import { PrismaClient } from "@/lib/generated/prisma/client";
|
||||
import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
|
||||
|
||||
// ── SQL 로깅 ($on) ────────────────────────────────────────────────
|
||||
|
||||
function inlineParams(query: string, rawParams: string): string {
|
||||
try {
|
||||
const params = JSON.parse(rawParams) as unknown[];
|
||||
let idx = 0;
|
||||
return query.replace(/\?/g, () => {
|
||||
const val = params[idx++];
|
||||
if (val === null || val === undefined) return "NULL";
|
||||
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
||||
if (typeof val === "boolean") return val ? "1" : "0";
|
||||
return String(val);
|
||||
});
|
||||
} catch {
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
function onQuery(e: Prisma.QueryEvent): void {
|
||||
const isSlow = e.duration >= SLOW_QUERY_MS;
|
||||
const level = isSlow ? "warn" : "debug";
|
||||
if (!logger.isEnabled(level)) return;
|
||||
|
||||
const isSensitive = e.query.includes("PASSWORD(");
|
||||
const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params);
|
||||
const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const tag = level.toUpperCase().padEnd(5);
|
||||
const slowLabel = isSlow ? " [SLOW]" : "";
|
||||
const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
|
||||
const indented = formatted
|
||||
.split("\n")
|
||||
.map((l) => ` ${l}`)
|
||||
.join("\n");
|
||||
|
||||
const out = `${header}\n${indented}`;
|
||||
if (isSlow) {
|
||||
console.error(out);
|
||||
} else {
|
||||
console.log(out);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 결과 로깅 ($extends) ──────────────────────────────────────────
|
||||
|
||||
const COL_MAX_WIDTH = 40;
|
||||
|
||||
function formatValue(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
|
||||
function isWide(cp: number): boolean {
|
||||
return (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
|
||||
(cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
||||
(cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
|
||||
(cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
|
||||
(cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
|
||||
(cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
|
||||
cp >= 0x20000 // CJK Extension B+
|
||||
);
|
||||
}
|
||||
|
||||
function displayWidth(s: string): number {
|
||||
let w = 0;
|
||||
for (const char of s) {
|
||||
w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
function truncateStr(s: string, maxWidth: number): string {
|
||||
if (displayWidth(s) <= maxWidth) return s;
|
||||
let w = 0;
|
||||
let result = "";
|
||||
for (const char of s) {
|
||||
const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
|
||||
if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
|
||||
w += cw;
|
||||
result += char;
|
||||
}
|
||||
return result + "…";
|
||||
}
|
||||
|
||||
function padEnd(s: string, width: number): string {
|
||||
const truncated = truncateStr(s, width);
|
||||
return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.min(
|
||||
COL_MAX_WIDTH,
|
||||
Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
|
||||
)
|
||||
);
|
||||
|
||||
const rule = (l: string, m: string, r: string, s: string) =>
|
||||
l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
|
||||
|
||||
const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
|
||||
const dRows = rows.map(
|
||||
(r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
|
||||
);
|
||||
|
||||
return [
|
||||
rule("┌", "┬", "┐", "─"),
|
||||
hRow,
|
||||
rule("├", "┼", "┤", "─"),
|
||||
...dRows,
|
||||
rule("└", "┴", "┘", "─"),
|
||||
]
|
||||
.map((l) => ` ${l}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildResultLog(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): string {
|
||||
const label = model ? `${model}.${operation}` : operation;
|
||||
|
||||
if (typeof result === "number") {
|
||||
return `${label} → ${result}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object" && "count" in result) {
|
||||
return `${label} → count: ${(result as { count: number }).count}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const total = result.length;
|
||||
if (total === 0) return `${label} → 0 rows`;
|
||||
|
||||
const preview = result.slice(0, 5);
|
||||
const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
|
||||
const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
|
||||
|
||||
if (typeof preview[0] === "object" && preview[0] !== null) {
|
||||
const objRows = preview as Record<string, unknown>[];
|
||||
const headers = Object.keys(objRows[0]);
|
||||
const rows = objRows.map((row) => headers.map((h) => formatValue(row[h])));
|
||||
return `${label} → ${countLabel}\n${renderTable(headers, rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
const rows = preview.map((v) => [formatValue(v)]);
|
||||
return `${label} → ${countLabel}\n${renderTable(["value"], rows)}${moreLabel}`;
|
||||
}
|
||||
|
||||
if (result !== null && typeof result === "object") {
|
||||
const rows = Object.entries(result as Record<string, unknown>).map(([k, v]) => [
|
||||
k,
|
||||
formatValue(v),
|
||||
]);
|
||||
return `${label}\n${renderTable(["field", "value"], rows)}`;
|
||||
}
|
||||
|
||||
return `${label} → ${String(result)}`;
|
||||
}
|
||||
|
||||
function logResult(
|
||||
model: string | undefined,
|
||||
operation: string,
|
||||
result: unknown
|
||||
): void {
|
||||
if (!logger.isEnabled("debug")) return;
|
||||
const ts = new Date().toISOString();
|
||||
const body = buildResultLog(model, operation, result);
|
||||
console.log(`[${ts}] [DEBUG] [db:result] ${body}`);
|
||||
}
|
||||
|
||||
// ── PrismaClient 생성 ─────────────────────────────────────────────
|
||||
|
||||
function createPrismaClient() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
@@ -14,8 +205,25 @@ function createPrismaClient() {
|
||||
}
|
||||
|
||||
const adapter = new PrismaMariaDb(databaseUrl);
|
||||
const baseClient = new PrismaClient({
|
||||
adapter,
|
||||
log: [{ emit: "event", level: "query" }],
|
||||
});
|
||||
|
||||
return new PrismaClient({ adapter });
|
||||
baseClient.$on("query", onQuery);
|
||||
|
||||
const extendedClient = baseClient.$extends({
|
||||
query: {
|
||||
$allOperations: async ({ model, operation, args, query }) => {
|
||||
const result = await query(args);
|
||||
logResult(model, operation, result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// $extends 타입은 PrismaClient의 상위 호환이므로 캐스트 안전
|
||||
return extendedClient as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
export const db = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
@@ -54,19 +56,10 @@ export type ExtensionRequestListResult = {
|
||||
status?: number | "all";
|
||||
};
|
||||
|
||||
export class ExtensionRequestActionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ExtensionRequestActionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExtensionRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<ExtensionRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseExtensionRequestSearchParams(rawSearchParams);
|
||||
const where = buildExtensionRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
@@ -96,6 +89,12 @@ export async function getExtensionRequests(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("extension-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toExtensionRequestListItem),
|
||||
page: params.page,
|
||||
@@ -194,11 +193,11 @@ async function getActionTarget(
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new ExtensionRequestActionError("연장 신청을 찾을 수 없습니다.", 404);
|
||||
throw new ApiError("연장 신청을 찾을 수 없습니다.", 404);
|
||||
}
|
||||
|
||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
||||
throw new ExtensionRequestActionError("이미 처리된 연장 신청입니다.", 409);
|
||||
throw new ApiError("이미 처리된 연장 신청입니다.", 409);
|
||||
}
|
||||
|
||||
return request;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const PRIORITY: Record<Level, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function getMinLevel(): Level {
|
||||
const env = process.env.LOG_LEVEL as Level | undefined;
|
||||
if (env && env in PRIORITY) return env;
|
||||
const appEnv = process.env.APP_ENV;
|
||||
return appEnv === "production" ? "info" : "debug";
|
||||
}
|
||||
|
||||
function shouldLog(level: Level): boolean {
|
||||
return PRIORITY[level] >= PRIORITY[getMinLevel()];
|
||||
}
|
||||
|
||||
function write(
|
||||
level: Level,
|
||||
ctx: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!shouldLog(level)) return;
|
||||
const ts = new Date().toISOString();
|
||||
const tag = level.toUpperCase().padEnd(5);
|
||||
const line = data
|
||||
? `[${ts}] [${tag}] [${ctx}] ${message} ${JSON.stringify(data)}`
|
||||
: `[${ts}] [${tag}] [${ctx}] ${message}`;
|
||||
if (level === "warn" || level === "error") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("debug", ctx, message, data),
|
||||
info: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("info", ctx, message, data),
|
||||
warn: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("warn", ctx, message, data),
|
||||
error: (ctx: string, message: string, data?: Record<string, unknown>) =>
|
||||
write("error", ctx, message, data),
|
||||
isEnabled: (level: Level): boolean => shouldLog(level),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
|
||||
const pageSizeOptions = [10, 20, 50, 100] as const;
|
||||
@@ -104,6 +105,7 @@ export type MaestroDetail = {
|
||||
export async function getMaestros(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<MaestroListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseMaestroListSearchParams(rawSearchParams);
|
||||
const where = buildMaestroWhere(params);
|
||||
const orderBy = buildMaestroOrderBy(params.sort);
|
||||
@@ -131,6 +133,12 @@ export async function getMaestros(
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / params.pageSize));
|
||||
|
||||
logger.info("maestros", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: maestros.map(toMaestroListItem),
|
||||
page: params.page,
|
||||
@@ -147,6 +155,7 @@ export async function getMaestros(
|
||||
export async function getMaestroDetail(
|
||||
maestroID: number
|
||||
): Promise<MaestroDetail | null> {
|
||||
const t0 = Date.now();
|
||||
const maestro = await db.maestro.findUnique({
|
||||
where: { MaestroID: maestroID },
|
||||
select: {
|
||||
@@ -164,6 +173,7 @@ export async function getMaestroDetail(
|
||||
});
|
||||
|
||||
if (!maestro) {
|
||||
logger.warn("maestros/[id]", "not found", { id: maestroID });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -229,6 +239,11 @@ export async function getMaestroDetail(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("maestros/[id]", "fetched", {
|
||||
id: maestroID,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
maestro: {
|
||||
...toMaestroListItem(maestro),
|
||||
|
||||
+11
-12
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ApiError } from "@/lib/errors";
|
||||
import { ACTIVATE_STATUSES, REQUEST_STATUSES } from "@/lib/constants";
|
||||
import type { Prisma } from "@/lib/generated/prisma/client";
|
||||
import {
|
||||
@@ -58,19 +60,10 @@ export type UpgradeRequestListResult = {
|
||||
status?: number | "all";
|
||||
};
|
||||
|
||||
export class UpgradeRequestActionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UpgradeRequestActionError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpgradeRequests(
|
||||
rawSearchParams: RawSearchParams = {}
|
||||
): Promise<UpgradeRequestListResult> {
|
||||
const t0 = Date.now();
|
||||
const params = parseUpgradeRequestSearchParams(rawSearchParams);
|
||||
const where = buildUpgradeRequestWhere(params);
|
||||
const skip = (params.page - 1) * params.pageSize;
|
||||
@@ -100,6 +93,12 @@ export async function getUpgradeRequests(
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.info("upgrade-requests", "list fetched", {
|
||||
totalCount,
|
||||
page: params.page,
|
||||
duration: Date.now() - t0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: requests.map(toUpgradeRequestListItem),
|
||||
page: params.page,
|
||||
@@ -198,14 +197,14 @@ async function getActionTarget(
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new UpgradeRequestActionError(
|
||||
throw new ApiError(
|
||||
"업그레이드 신청을 찾을 수 없습니다.",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
if (request.Status !== REQUEST_STATUSES.REQUESTED) {
|
||||
throw new UpgradeRequestActionError(
|
||||
throw new ApiError(
|
||||
"이미 처리된 업그레이드 신청입니다.",
|
||||
409
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user