Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bds-log-filter-solid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@sfmc-bds/sdk": patch
"@sfmc-bds/cli": patch
---

fix(logs/config): BDS 级别解析 DRY 到 SDK;剥前缀后勿误判 Error;readJson 剥 BOM;log-filter 走 ensureSchemaConfig
2 changes: 1 addition & 1 deletion modules/sdk/@sfmc-sdk/schemas/log_filter.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
},
"regex": {
"type": "string",
"description": "JS RegExp 源字符串(可用 (?i) 等旗标语法视引擎支持;推荐显式 /i 需求时在模式内写)"
"description": "JS RegExp 源字符串;仅支持前缀 (?i) 表示忽略大小写(引擎无内联 (?i))"
}
}
}
Expand Down
64 changes: 52 additions & 12 deletions modules/sdk/@sfmc-sdk/src/logs/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions modules/sdk/@sfmc-sdk/src/logs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type {

export {
inferLevel,
mapBdsLevelToken,
parseBdsEmbeddedLevel,
stripBdsLogPrefix,
padSource,
levelTag,
levelTagFull,
Expand Down
7 changes: 6 additions & 1 deletion modules/sdk/@sfmc-sdk/src/node/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,12 @@ export function modulePath(dir: string, name: ModuleFileName): string {

export function readJson<T>(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;
}
Expand Down
31 changes: 12 additions & 19 deletions sfmc/src/log-filter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>, "log_filter");
fs.writeFileSync(p, `${JSON.stringify(seeded, null, 2)}\n`, "utf8");
}
return p;
ensureSchemaConfig(ROOT, "log-filter.json", "log_filter", { ...DEFAULTS } as Record<string, unknown>);
return logFilterConfigPath();
}

type CompiledRule = {
Expand Down Expand Up @@ -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<string, unknown>;
cfg = normalizeConfig(raw);
} catch {
cfg = { ...DEFAULTS };
const raw = readJson<Record<string, unknown>>(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,
Expand Down
92 changes: 8 additions & 84 deletions sfmc/src/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
createMemoryBuffer,
formatLogLine,
inferLevel as sharedInferLevel,
parseBdsEmbeddedLevel,
stripBdsLogPrefix,
type FileSink,
type LogEntry,
type LogLevel as SharedLogLevel,
Expand All @@ -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 */
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions sfmc/wrap-log-line.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down