From a5ccbd3b0e2d3f45ed13b03f06dc0df3b898dec0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 01:42:33 +0000 Subject: [PATCH] =?UTF-8?q?fix(logs/config):=20BDS=20=E7=BA=A7=E5=88=AB?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20DRY=20=E5=85=A5=20SDK=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=89=A5=E5=89=8D=E7=BC=80=E5=90=8E=20Error=20=E8=AF=AF?= =?UTF-8?q?=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pushLog 剥离 BDS 时间戳后,旧 getLogLevel 会把正文「Error on line」抬成 error。 将 parse/strip/map 收敛到 @sfmc-bds/sdk/logs;readJson 剥 BOM;log-filter 走 ensureSchemaConfig。 Co-authored-by: Shiroha --- .changeset/bds-log-filter-solid.md | 6 ++ .../@sfmc-sdk/schemas/log_filter.schema.json | 2 +- modules/sdk/@sfmc-sdk/src/logs/format.ts | 64 ++++++++++--- modules/sdk/@sfmc-sdk/src/logs/index.ts | 3 + .../sdk/@sfmc-sdk/src/node/config/index.ts | 7 +- sfmc/src/log-filter.ts | 31 +++---- sfmc/src/logs.ts | 92 ++----------------- sfmc/wrap-log-line.test.mjs | 2 + 8 files changed, 90 insertions(+), 117 deletions(-) create mode 100644 .changeset/bds-log-filter-solid.md diff --git a/.changeset/bds-log-filter-solid.md b/.changeset/bds-log-filter-solid.md new file mode 100644 index 0000000..5f58462 --- /dev/null +++ b/.changeset/bds-log-filter-solid.md @@ -0,0 +1,6 @@ +--- +"@sfmc-bds/sdk": patch +"@sfmc-bds/cli": patch +--- + +fix(logs/config): BDS 级别解析 DRY 到 SDK;剥前缀后勿误判 Error;readJson 剥 BOM;log-filter 走 ensureSchemaConfig diff --git a/modules/sdk/@sfmc-sdk/schemas/log_filter.schema.json b/modules/sdk/@sfmc-sdk/schemas/log_filter.schema.json index 95f85ae..20da4d1 100644 --- a/modules/sdk/@sfmc-sdk/schemas/log_filter.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/log_filter.schema.json @@ -54,7 +54,7 @@ }, "regex": { "type": "string", - "description": "JS RegExp 源字符串(可用 (?i) 等旗标语法视引擎支持;推荐显式 /i 需求时在模式内写)" + "description": "JS RegExp 源字符串;仅支持前缀 (?i) 表示忽略大小写(引擎无内联 (?i))" } } } diff --git a/modules/sdk/@sfmc-sdk/src/logs/format.ts b/modules/sdk/@sfmc-sdk/src/logs/format.ts index e7d1af3..e5e2ce3 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/format.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/format.ts @@ -9,19 +9,59 @@ import type { FormatOptions, LogEntry, LogLevel } from "./types.js"; import { ansi, visibleLen, wrap } from "./ansi.js"; -/** 从原始文本推断日志级别 (关键词匹配,兼容子进程各种前缀风格) */ +/** + * BDS 行首时间戳+级别(大小写不敏感:WARN / warn / Warning 均可)。 + * Bedrock 常见为 WARN(非 WARNING);WARNING 一并兼容。 + * 权威源:本模块;sfmc 展示层应委托而非再抄一份正则。 + */ +const BDS_LEVEL_TOKEN = "INFO|WARN(?:ING)?|ERROR|FATAL|DEBUG|VERBOSE|TRACE"; +const BDS_TS_PREFIX_RE = new RegExp( + `^\\[\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:[.,:]\\d{1,3})?\\s+(${BDS_LEVEL_TOKEN})\\]\\s*`, + "i" +); +/** 正文任意位置的 BDS 时间戳级别(防行首有杂讯时仍能提取) */ +const BDS_TS_LEVEL_ANYWHERE_RE = new RegExp( + `\\[\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:[.,:]\\d{1,3})?\\s+(${BDS_LEVEL_TOKEN})\\]`, + "i" +); + +/** 将 BDS 级别词映射为 LogLevel(大小写不敏感) */ +export function mapBdsLevelToken(token: string): LogLevel { + const u = String(token ?? "").toUpperCase(); + if (u === "ERROR" || u === "FATAL") return "error"; + if (u === "WARN" || u === "WARNING") return "warn"; + if (u === "DEBUG" || u === "TRACE" || u === "VERBOSE") return "debug"; + return "info"; +} + +/** + * 去掉 BDS 自带的 `[时间 等级]` 前缀,正文从真正消息开始。 + * 幂等:已剥离过的行原样返回。 + */ +export function stripBdsLogPrefix(line: string): string { + return String(line ?? "") + .replace(/^\uFEFF/, "") + .replace(/^\s+/, "") + .replace(BDS_TS_PREFIX_RE, ""); +} + +/** + * 从一行文本提取 BDS 内嵌级别(优先行首前缀,其次全文首次命中)。 + * 例:`[2026-07-25 18:42:27:626 warn] [Commands] ...` → warn + */ +export function parseBdsEmbeddedLevel(line: string): LogLevel | null { + const s = String(line ?? ""); + const head = BDS_TS_PREFIX_RE.exec(s.replace(/^\uFEFF/, "").replace(/^\s+/, "")); + if (head?.[1]) return mapBdsLevelToken(head[1]); + const any = BDS_TS_LEVEL_ANYWHERE_RE.exec(s); + if (any?.[1]) return mapBdsLevelToken(any[1]); + return null; +} + +/** 从原始文本推断日志级别 (BDS 时间戳前缀优先,再关键词匹配) */ export function inferLevel(text: string): LogLevel { - /* BDS: [YYYY-MM-DD HH:MM:SS:mmm WARN|ERROR|...] — WARN 前后是空格而非 [WARN */ - const bds = /\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,:]\d{1,3})?\s+(INFO|WARN(?:ING)?|ERROR|FATAL|DEBUG|VERBOSE|TRACE)\]/i.exec( - text - ); - if (bds?.[1]) { - const u = bds[1].toUpperCase(); - if (u === "ERROR" || u === "FATAL") return "error"; - if (u === "WARN" || u === "WARNING") return "warn"; - if (u === "DEBUG" || u === "TRACE" || u === "VERBOSE") return "debug"; - return "info"; - } + const embedded = parseBdsEmbeddedLevel(text); + if (embedded) return embedded; const t = text.toUpperCase(); if (t.includes("[FATAL]") || t.includes("[ERROR]") || t.includes("[X]")) return "error"; diff --git a/modules/sdk/@sfmc-sdk/src/logs/index.ts b/modules/sdk/@sfmc-sdk/src/logs/index.ts index f09c720..b5d3ca1 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/index.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/index.ts @@ -15,6 +15,9 @@ export type { export { inferLevel, + mapBdsLevelToken, + parseBdsEmbeddedLevel, + stripBdsLogPrefix, padSource, levelTag, levelTagFull, diff --git a/modules/sdk/@sfmc-sdk/src/node/config/index.ts b/modules/sdk/@sfmc-sdk/src/node/config/index.ts index 0095734..56bbe85 100644 --- a/modules/sdk/@sfmc-sdk/src/node/config/index.ts +++ b/modules/sdk/@sfmc-sdk/src/node/config/index.ts @@ -315,7 +315,12 @@ export function modulePath(dir: string, name: ModuleFileName): string { export function readJson(filePath: string, fallback?: T): T | undefined { try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; + /* 与 bds-tools/fsx.readJsonFile 同契约:剥离 UTF-8 BOM 后再解析 */ + let text = fs.readFileSync(filePath, "utf-8"); + if (text.length > 0 && text.charCodeAt(0) === 0xfeff) { + text = text.slice(1); + } + return JSON.parse(text) as T; } catch { if (fallback !== undefined) return fallback; } diff --git a/sfmc/src/log-filter.ts b/sfmc/src/log-filter.ts index 010460f..fe5f7fa 100644 --- a/sfmc/src/log-filter.ts +++ b/sfmc/src/log-filter.ts @@ -1,7 +1,7 @@ /** * log-filter.ts — configs/log-filter.json 加载与匹配(纯判定 + 热读缓存) */ -import { stripConfigMeta, withConfigSchema } from "@sfmc-bds/sdk/node/config"; +import { ensureSchemaConfig, readJson, stripConfigMeta } from "@sfmc-bds/sdk/node/config"; import type { LogLevel } from "@sfmc-bds/sdk/logs"; import fs from "node:fs"; import path from "node:path"; @@ -43,15 +43,10 @@ export function logFilterConfigPath(): string { return path.join(ROOT, "configs", "log-filter.json"); } -/** 确保 configs/log-filter.json 存在 */ +/** 确保 configs/log-filter.json 存在(委托 SDK ensureSchemaConfig,禁止手写 withConfigSchema) */ export function ensureLogFilterConfigFile(): string { - const p = logFilterConfigPath(); - fs.mkdirSync(path.dirname(p), { recursive: true }); - if (!fs.existsSync(p)) { - const seeded = withConfigSchema({ ...DEFAULTS } as unknown as Record, "log_filter"); - fs.writeFileSync(p, `${JSON.stringify(seeded, null, 2)}\n`, "utf8"); - } - return p; + ensureSchemaConfig(ROOT, "log-filter.json", "log_filter", { ...DEFAULTS } as Record); + return logFilterConfigPath(); } type CompiledRule = { @@ -148,18 +143,16 @@ export function getCompiledLogFilter(onBadRegex?: (src: string) => void): Compil if (cache && cache.mtimeMs === mtimeMs) return cache; let cfg = { ...DEFAULTS }; - try { - const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Record; - cfg = normalizeConfig(raw); - } catch { - cfg = { ...DEFAULTS }; + const raw = readJson>(p); + if (raw) { + try { + cfg = normalizeConfig(raw); + } catch { + cfg = { ...DEFAULTS }; + } } - const bad: string[] = []; - const rules = compileRules(cfg.rules, (src) => { - bad.push(src); - onBadRegex?.(src); - }); + const rules = compileRules(cfg.rules, onBadRegex); cache = { enabled: cfg.enabled, diff --git a/sfmc/src/logs.ts b/sfmc/src/logs.ts index 497e40d..e3f5ecd 100644 --- a/sfmc/src/logs.ts +++ b/sfmc/src/logs.ts @@ -18,6 +18,8 @@ import { createMemoryBuffer, formatLogLine, inferLevel as sharedInferLevel, + parseBdsEmbeddedLevel, + stripBdsLogPrefix, type FileSink, type LogEntry, type LogLevel as SharedLogLevel, @@ -35,6 +37,9 @@ export type LogLevel = SharedLogLevel; export type LogSource = string; export interface UnifiedLog extends LogEntry {} +/** 再导出 SDK BDS 解析(测试与调用方统一入口,权威在 @sfmc-bds/sdk/logs) */ +export { mapBdsLevelToken, parseBdsEmbeddedLevel, stripBdsLogPrefix } from "@sfmc-bds/sdk/logs"; + const buffer = createMemoryBuffer(5000); /** 子进程已用 createNodeServiceLogger 自行落盘的 source */ @@ -164,100 +169,19 @@ export function formatSourceTag(source: string): string { } /** - * BDS 行首时间戳+级别(大小写不敏感:WARN / warn / Warning 均可)。 - * Bedrock 常见为 WARN(非 WARNING);WARNING 一并兼容。 - */ -const BDS_LEVEL_TOKEN = "INFO|WARN(?:ING)?|ERROR|FATAL|DEBUG|VERBOSE|TRACE"; -const BDS_TS_PREFIX_RE = new RegExp( - `^\\[\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:[.,:]\\d{1,3})?\\s+(${BDS_LEVEL_TOKEN})\\]\\s*`, - "i" -); -/** 正文任意位置的 BDS 时间戳级别(防行首有杂讯时仍能提取) */ -const BDS_TS_LEVEL_ANYWHERE_RE = new RegExp( - `\\[\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}(?:[.,:]\\d{1,3})?\\s+(${BDS_LEVEL_TOKEN})\\]`, - "i" -); - -/** - * 去掉 BDS 自带的 `[时间 等级]` 前缀,正文从真正消息开始。 - * 幂等:已剥离过的行原样返回。大小写不敏感(warn / WARN / Warning)。 - */ -export function stripBdsLogPrefix(line: string): string { - return String(line ?? "") - .replace(/^\uFEFF/, "") - .replace(/^\s+/, "") - .replace(BDS_TS_PREFIX_RE, ""); -} - -/** 将 BDS 级别词映射为 UnifiedLog 级别(大小写不敏感) */ -export function mapBdsLevelToken(token: string): LogLevel { - const u = String(token ?? "").toUpperCase(); - if (u === "ERROR" || u === "FATAL") return "error"; - if (u === "WARN" || u === "WARNING") return "warn"; - if (u === "DEBUG" || u === "TRACE" || u === "VERBOSE") return "debug"; - return "info"; -} - -/** - * 从一行文本提取 BDS 内嵌级别(优先行首前缀,其次全文首次命中)。 - * 例:`[2026-07-25 18:42:27:626 warn] [Commands] ...` → warn - */ -export function parseBdsEmbeddedLevel(line: string): LogLevel | null { - const s = String(line ?? ""); - const head = BDS_TS_PREFIX_RE.exec(s.replace(/^\uFEFF/, "").replace(/^\s+/, "")); - if (head?.[1]) return mapBdsLevelToken(head[1]); - const any = BDS_TS_LEVEL_ANYWHERE_RE.exec(s); - if (any?.[1]) return mapBdsLevelToken(any[1]); - return null; -} - -/** - * 从一行 BDS 日志中提取日志等级词(大写)。 - * @returns 日志等级(大写),若无法识别则返回 'UNKNOWN' + * 展示用级别:BDS 若仍带时间戳前缀则解析内嵌级别;已剥离(pushLog 入库)则信任 entry.level。 + * 禁止对正文做松散关键词匹配(会把 `[Commands] Error on line` 误判为 error)。 */ -function getLogLevel(line: string): string { - const embedded = parseBdsEmbeddedLevel(line); - if (embedded === "error") return "ERROR"; - if (embedded === "warn") return "WARN"; - if (embedded === "debug") return "DEBUG"; - if (embedded === "info") return "INFO"; - - const levelNames = ["INFO", "WARNING", "ERROR", "FATAL", "DEBUG", "TRACE", "WARN", "VERBOSE"]; - const levelPattern = levelNames.join("|"); - - let match = line.match(new RegExp(`\\[(${levelPattern})\\]`, "i")); - if (match?.[1]) return match[1].toUpperCase(); - - match = line.match(new RegExp(`^\\[.*?\\]\\s*(${levelPattern})`, "i")); - if (match?.[1]) return match[1].toUpperCase(); - - match = line.match(new RegExp(`^(${levelPattern})\\s*:`, "i")); - if (match?.[1]) return match[1].toUpperCase(); - - match = line.match(new RegExp(`\\[.*?\\]\\s*(${levelPattern})\\s*:`, "i")); - if (match?.[1]) return match[1].toUpperCase(); - - return "UNKNOWN"; -} - -/** 展示用级别:BDS 行优先解析内嵌 WARN/ERROR,其余用 entry.level */ export function resolveDisplayLevel(l: UnifiedLog): LogLevel { if (l.source === "bds") { const embedded = parseBdsEmbeddedLevel(l.text); if (embedded) return embedded; - const parsed = getLogLevel(l.text); - if (parsed === "WARNING" || parsed === "WARN") return "warn"; - if (parsed === "ERROR" || parsed === "FATAL") return "error"; - if (parsed === "DEBUG" || parsed === "TRACE" || parsed === "VERBOSE") return "debug"; - if (parsed === "INFO") return "info"; } return l.level; } -/** 从原始文本推断级别:BDS 内嵌级别优先,再委托共享包 */ +/** 从原始文本推断级别:委托共享包(内含 BDS 时间戳解析) */ export function inferLevel(text: string): LogLevel { - const embedded = parseBdsEmbeddedLevel(text); - if (embedded) return embedded; return sharedInferLevel(text); } diff --git a/sfmc/wrap-log-line.test.mjs b/sfmc/wrap-log-line.test.mjs index 327b45e..2eade96 100644 --- a/sfmc/wrap-log-line.test.mjs +++ b/sfmc/wrap-log-line.test.mjs @@ -139,6 +139,8 @@ test("resolveDisplayLevel:BDS 行从正文解析,其余用 entry.level", asy assert.ok(last); assert.equal(last.level, "warn"); assert.equal(last.text, "[Commands] Error on line 4: unexpected /"); + /* 入库后正文含 Error 字样,不得把展示级别抬成 error(旧 getLogLevel 松散匹配的坑) */ + assert.equal(resolveDisplayLevel(last), "warn"); assert.ok(getAllLogs().length === before + 1); assert.equal(