Files
web-table/docs/superpowers/plans/2026-06-24-structured-logging.md

455 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 结构化日志log4js fileSyncImplementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 给生产数据查询工具加上结构化、分级、同步落盘、自带轮转的服务端日志,聚焦错误与崩溃诊断(尤其 export OOM
**Architecture:** log4js 的 `fileSync` appender同步写 = 崩溃前可靠落盘)写到 `logs/app/app.log`10MB×5 轮转),由 `src/server/logger.ts` 单例配置;纯函数 `formatError` 抽到 `log-format.ts` 单测;两个 API 路由替换 `console.*` 为带完整堆栈与耗时字段的 logger 调用。NSSM 的 stdout/stderr 保持不动作兜底。
**Tech Stack:** Next 16.2.9route handlersserver-only、log4js 6.9.1新增依赖、vitestnode 环境,仅测纯逻辑)。
## Global Constraints
- 新增依赖 **log4js@6.9.1**,用其 **`fileSync`** appender同步写。部署到 114 时**必须** `npm install`
- logger 仅在服务端API route使用依赖 Node `fs`;不得被客户端组件引入。
- 日志文件:项目根下 `logs/app/app.log``maxLogSize=10MB``backups=5`pattern 布局 `[%d{ISO8601}] [%p] [%c] %m`,默认级别 `info`
- 测试vitest`environment: node`,匹配 `src/**/*.test.ts`)。**仅** `formatError` 做单测;路由日志为副作用,手验。
- 不改 `dbConfig` 重复、不改业务返回逻辑、不动 NSSM stdout/stderr。
- commit message 用英文;实现后启动本地 `next dev` 供验证,**通过后再部署**。
## File Structure
| 文件 | 责任 | 动作 |
|------|------|------|
| `src/server/log-format.ts` | 纯函数 `formatError(err)`:提取堆栈/字符串 | 新增 |
| `src/server/log-format.test.ts` | `formatError` 单测 | 新增 |
| `src/server/logger.ts` | log4js configurefileSync+ 导出 `apiLogger`/`exportLogger` + 再导出 `formatError` | 新增 |
| `src/app/api/production-data/route.ts` | 失败 ERROR带栈+workshopNo+dur、慢查询 WARN(>3s) | 修改 |
| `src/app/api/export-excel/route.ts` | 锁占用/回收 WARN、开始/成功 INFO(rows/bytes/dur)、失败 ERROR(带栈+dur) | 修改 |
| `package.json` | +`log4js` | 修改 |
---
## Task 1: log4js 依赖 + `formatError`TDD+ logger 模块
**Files:**
- Create: `src/server/log-format.ts`
- Test: `src/server/log-format.test.ts`
- Create: `src/server/logger.ts`
- Modify: `package.json``npm install log4js`
**Interfaces:**
- Produces:
- `formatError(err: unknown): string`(来自 `log-format.ts``logger.ts` 再导出)。
- `apiLogger``exportLogger`log4js Logger 实例category 分别为 `"api"``"export"`)。
- [ ] **Step 1: 安装依赖**
Run: `npm install log4js`
Expected: `added N packages``package.json` 出现 `"log4js": "^6.9.1"`
- [ ] **Step 2: 写失败测试**
Create `src/server/log-format.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { formatError } from "./log-format";
describe("formatError", () => {
it("returns stack for Error", () => {
const e = new Error("boom");
expect(formatError(e)).toBe(e.stack);
});
it("falls back to message when stack missing", () => {
const e = new Error("boom");
e.stack = undefined as unknown as string;
expect(formatError(e)).toBe("boom");
});
it("stringifies non-Error primitives", () => {
expect(formatError("oops")).toBe("oops");
expect(formatError(42)).toBe("42");
});
it("handles null / undefined", () => {
expect(formatError(null)).toBe("Unknown error");
expect(formatError(undefined)).toBe("Unknown error");
});
});
```
- [ ] **Step 3: 运行测试确认失败**
Run: `npx vitest run src/server/log-format.test.ts`
Expected: FAIL — `Cannot find module './log-format'`
- [ ] **Step 4: 写 `formatError` 实现**
Create `src/server/log-format.ts`:
```ts
/**
* 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
* - Error优先 stack缺则 message
* - null/undefined'Unknown error'
* - 其它String(value)
*/
export function formatError(err: unknown): string {
if (err instanceof Error) return err.stack || err.message;
if (err === null || err === undefined) return "Unknown error";
return String(err);
}
```
- [ ] **Step 5: 运行测试确认通过**
Run: `npx vitest run src/server/log-format.test.ts`
Expected: PASS4 用例)。
- [ ] **Step 6: 写 logger 模块**
Create `src/server/logger.ts`:
```ts
import log4js from "log4js";
// fileSync = 同步写:进程 OOM/被杀前日志已落盘,满足崩溃诊断刚需。
log4js.configure({
appenders: {
app: {
type: "fileSync",
filename: "logs/app/app.log",
maxLogSize: 10 * 1024 * 1024, // 10MB
backups: 5,
layout: { type: "pattern", pattern: "[%d{ISO8601}] [%p] [%c] %m" },
},
},
categories: { default: { appenders: ["app"], level: "info" } },
});
export { formatError } from "./log-format";
export const apiLogger = log4js.getLogger("api");
export const exportLogger = log4js.getLogger("export");
```
- [ ] **Step 7: 跑全量测试 + lint**
Run: `npm test`
Expected: 全部 PASS`table-filters`/`record-detail` + 新 `log-format`)。
Run: `npm run lint`
Expected: 0 error`log-format.ts` 被 eslint 覆盖;`logger.ts` 仅在服务端被路由引用,不进客户端)。
- [ ] **Step 8: 提交**
```bash
git add package.json package-lock.json src/server/log-format.ts src/server/log-format.test.ts src/server/logger.ts
git commit -m "Add log4js fileSync logger module and formatError helper"
```
---
## Task 2: 给 `/api/production-data` 接日志
**Files:**
- Modify: `src/app/api/production-data/route.ts`
**Interfaces:**
- Consumes: Task 1 的 `apiLogger``formatError``import { apiLogger, formatError } from "../../../server/logger"`)。
- [ ] **Step 1: 改 import 与加常量**
在文件顶部 import 区,`import sql from "mssql";` 之后加:
```ts
import { apiLogger, formatError } from "../../../server/logger";
const SLOW_QUERY_MS = 3000;
```
- [ ] **Step 2: 计时 + 失败带栈 + 慢查询 WARN**
`export async function GET(request: NextRequest) {` 内的 try/catch/finally 改为(`dbConfig` 不变,中间序列化逻辑不变,仅在外围加计时与日志):
```ts
let pool: sql.ConnectionPool | undefined;
const t0 = Date.now();
try {
pool = await sql.connect(dbConfig);
const result = await pool
.request()
.input("车间号", sql.NVarChar(50), workshopNo)
.execute("[productionContractData].[sp_压力表合同生产数据_按车间号]");
const recordset = result.recordset;
if (!recordset || recordset.length === 0) {
return NextResponse.json({ columns: [], data: [], total: 0 });
}
const columns = Object.keys(recordset[0]);
const data = recordset.map((row: Record<string, unknown>) => {
const serialized: Record<string, unknown> = {};
for (const key of columns) {
const val = row[key];
if (val instanceof Date) {
serialized[key] = val.toISOString().split("T")[0];
} else {
serialized[key] = val;
}
}
return serialized;
});
const dur = Date.now() - t0;
if (dur > SLOW_QUERY_MS) {
apiLogger.warn(`slow query · workshopNo=${workshopNo} · dur=${dur}ms · rows=${data.length}`);
}
return NextResponse.json({ columns, data, total: data.length });
} catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error";
apiLogger.error(`query failed · workshopNo=${workshopNo} · dur=${dur}ms · ${formatError(err)}`);
return NextResponse.json({ error: message }, { status: 500 });
} finally {
if (pool) {
await pool.close();
}
}
```
> 注意:删除原 `console.error("DB query error:", message);`,由 `apiLogger.error(...)` 取代。
- [ ] **Step 3: 测试 + lint**
Run: `npm test && npm run lint`
Expected: 测试全 PASS、lint 0 error。
- [ ] **Step 4: 提交**
```bash
git add src/app/api/production-data/route.ts
git commit -m "Log production-data query failures (with stack) and slow queries"
```
---
## Task 3: 给 `/api/export-excel` 接日志
**Files:**
- Modify: `src/app/api/export-excel/route.ts`
**Interfaces:**
- Consumes: Task 1 的 `exportLogger``formatError``import { exportLogger, formatError } from "../../../server/logger"`)。
- [ ] **Step 1: 加 import**
`import ExcelJS from "exceljs";` 之后加:
```ts
import { exportLogger, formatError } from "../../../server/logger";
```
- [ ] **Step 2: 锁回收 WARN替换 console.warn**
`acquireExportLock` 里的:
```ts
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
console.warn("Export lock reclaimed after timeout");
exportLocked = false;
}
```
改为:
```ts
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
exportLogger.warn("export lock reclaimed after timeout");
exportLocked = false;
}
```
- [ ] **Step 3: 429 占用 WARN + 计时 + 成功 INFO + 失败 ERROR**
`export async function GET() {` 改为(返回逻辑/锁释放不变,仅加日志与计时):
```ts
export async function GET() {
if (!acquireExportLock()) {
exportLogger.warn("export skipped: another export in progress (429)");
return NextResponse.json(
{ error: "正在导出,请稍候" },
{ status: 429, headers: { "Retry-After": "3" } }
);
}
const t0 = Date.now();
exportLogger.info("export started");
let pool: sql.ConnectionPool | undefined;
try {
pool = await sql.connect(dbConfig);
const result = await pool
.request()
.execute("[productionContractData].[sp_压力表合同生产数据_全部]");
const recordset = result.recordset;
if (!recordset || recordset.length === 0) {
exportLogger.warn("export empty: no data");
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
}
const columns = Object.keys(recordset[0]);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("压力表合同生产数据");
// Header row
const headerRow = sheet.addRow(columns);
headerRow.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE0EAF6" },
};
cell.alignment = { horizontal: "center" };
});
// Data rows
for (const row of recordset) {
const values = columns.map((col) => {
const val = row[col];
if (val instanceof Date) {
return val;
}
if (val === null || val === undefined) return "";
return val;
});
sheet.addRow(values);
}
// Format date columns and auto-width
const dateColIndices: number[] = [];
const maxWidths: number[] = columns.map((col) => col.length);
columns.forEach((col, idx) => {
if (DATE_COLUMNS.has(col)) {
dateColIndices.push(idx + 1);
}
});
sheet.eachRow((row, rowNumber) => {
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
if (rowNumber > 1 && dateColIndices.includes(colNumber)) {
if (cell.value instanceof Date) {
cell.numFmt = "yyyy/mm/dd";
}
}
const text = cell.text || "";
const width = Math.max(maxWidths[colNumber - 1] || 0, text.length + 2);
maxWidths[colNumber - 1] = width;
});
});
columns.forEach((_, idx) => {
sheet.getColumn(idx + 1).width = Math.min(Math.max(maxWidths[idx], 8), 50);
});
sheet.views = [{ state: "frozen", ySplit: 1 }];
const buffer = await workbook.xlsx.writeBuffer();
const bytes = typeof buffer === "object" && buffer && "byteLength" in buffer
? (buffer as { byteLength: number }).byteLength
: 0;
const dur = Date.now() - t0;
exportLogger.info(`export ok · rows=${recordset.length} · bytes=${bytes} · dur=${dur}ms`);
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition":
"attachment; filename*=UTF-8''" + encodeURIComponent("压力表合同生产数据.xlsx"),
},
});
} catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error";
exportLogger.error(`export failed · dur=${dur}ms · ${formatError(err)}`);
return NextResponse.json({ error: message }, { status: 500 });
} finally {
if (pool) {
await pool.close();
}
releaseExportLock();
}
}
```
> 注意:删除原 `console.error("Excel export error:", message);`,由 `exportLogger.error(...)` 取代。
- [ ] **Step 4: 测试 + lint**
Run: `npm test && npm run lint`
Expected: 测试全 PASS、lint 0 error。
- [ ] **Step 5: 提交**
```bash
git add src/app/api/export-excel/route.ts
git commit -m "Log export lifecycle (start/ok/fail/lock) with stack and timing"
```
---
## Task 4: 本地验证(不部署)
- [ ] **Step 1: 启动本地服务**
Run后台: `npm run dev`
打开: http://localhost:3000
- [ ] **Step 2: 触发 production-data 失败,确认 ERROR 带栈**
临时让 DB 查询失败(如临时把 `.env.local``DB_SERVER` 改成不可达值再重启,或直接断网/停 DB查询一个车间号。
Expected: `logs/app/app.log` 出现一行:
`[...] [ERROR] [api] query failed · workshopNo=<值> · dur=<N>ms · <Error 类型>: <msg>\n at ...`**含完整堆栈**)。
- [ ] **Step 3: 触发导出,确认 INFO/ERROR**
恢复 DB查询出数据后点「导出全部」。
Expected: `logs/app/app.log` 依次出现:
- `[INFO] [export] export started`
- `[INFO] [export] export ok · rows=<N> · bytes=<N> · dur=<N>ms`
(可选)导出过程中再点一次导出,确认 `[WARN] [export] export skipped: another export in progress (429)`
- [ ] **Step 4: 确认轮转目录**
确认 `logs/app/` 目录被创建,`app.log` 存在NSSM 的 `logs/stdout.log`/`stderr.log` 不再混入这些应用日志(只有 Next 框架输出)。
- [ ] **Step 5: 停止本地服务 + 推送**
Run: 停止 dev`git push`
> 部署到 114pull → `npm install`(新增 log4js→ `next build` → 重启 WebTable。验证通过后再执行。
---
## Self-Review
**Spec coverage:**
- 4.1 log4js fileSync → Task 1 Step 6 ✅
- 4.2 分层(不动 NSSM→ Global Constraints + Task 4 Step 4 ✅
- 4.3 logger 模块 + 输出样例 → Task 1 Step 6pattern 与字段在路由日志中体现)✅
- 4.4 记什么矩阵production 失败 ERROR / 慢查询 WARN → Task 2export 锁占用/回收 WARN、开始/成功 INFO、失败 ERROR、启动 INFO → Task 3 Step 3`export started`console.* 全替换 → Task 2/3 ✅
- 4.5 formatError + 路由带栈 → Task 1formatError+ Task 2/3带栈
- 4.6 涉及文件 → File Structure ✅
- 5 边界OOM 落盘/目录/轮转/dev 与生产)→ Global Constraints + Task 4 ✅
- 6 测试formatError 单测 + 手验)→ Task 1 Step 2-5 + Task 4 ✅
- 7 部署注意npm install→ Global Constraints + Task 4 Step 5 ✅
**Placeholder scan:** 无 TBD/TODO每步含完整代码与命令。
**Type consistency:** `formatError(err: unknown): string` 在 Task 1 定义Task 2/3 调用一致;`apiLogger`/`exportLogger` 来自 Task 1 的 `logger.ts`import 路径 `../../../server/logger`(两路由同深,正确)。