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

16 KiB
Raw Permalink Blame History

结构化日志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.log10MB×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.logmaxLogSize=10MBbackups=5pattern 布局 [%d{ISO8601}] [%p] [%c] %m,默认级别 info
  • 测试vitestenvironment: 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 依赖 + formatErrorTDD+ logger 模块

Files:

  • Create: src/server/log-format.ts
  • Test: src/server/log-format.test.ts
  • Create: src/server/logger.ts
  • Modify: package.jsonnpm install log4js

Interfaces:

  • Produces:

    • formatError(err: unknown): string(来自 log-format.tslogger.ts 再导出)。
    • apiLoggerexportLoggerlog4js Logger 实例category 分别为 "api""export")。
  • Step 1: 安装依赖

Run: npm install log4js Expected: added N packagespackage.json 出现 "log4js": "^6.9.1"

  • Step 2: 写失败测试

Create src/server/log-format.test.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:

/**
 * 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
 * - 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:

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: 全部 PASStable-filters/record-detail + 新 log-format)。

Run: npm run lint Expected: 0 errorlog-format.ts 被 eslint 覆盖;logger.ts 仅在服务端被路由引用,不进客户端)。

  • Step 8: 提交
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 的 apiLoggerformatErrorimport { apiLogger, formatError } from "../../../server/logger")。

  • Step 1: 改 import 与加常量

在文件顶部 import 区,import sql from "mssql"; 之后加:

import { apiLogger, formatError } from "../../../server/logger";

const SLOW_QUERY_MS = 3000;
  • Step 2: 计时 + 失败带栈 + 慢查询 WARN

export async function GET(request: NextRequest) { 内的 try/catch/finally 改为(dbConfig 不变,中间序列化逻辑不变,仅在外围加计时与日志):

  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: 提交
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 的 exportLoggerformatErrorimport { exportLogger, formatError } from "../../../server/logger")。

  • Step 1: 加 import

import ExcelJS from "exceljs"; 之后加:

import { exportLogger, formatError } from "../../../server/logger";
  • Step 2: 锁回收 WARN替换 console.warn

acquireExportLock 里的:

  if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
    console.warn("Export lock reclaimed after timeout");
    exportLocked = false;
  }

改为:

  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() { 改为(返回逻辑/锁释放不变,仅加日志与计时):

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: 提交
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.localDB_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: 停止 devgit push

部署到 114pull → npm install(新增 log4jsnext 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 3export startedconsole.* 全替换 → 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.tsimport 路径 ../../../server/logger(两路由同深,正确)。