增加feifei 日志

This commit is contained in:
yml2213
2026-07-07 16:04:54 +08:00
parent affba7e924
commit 59f6f5ee1e
4 changed files with 347 additions and 97 deletions
+79 -55
View File
@@ -28,7 +28,10 @@ test('shouldWriteLog respects configured minimum log level', () => {
test('resolveLogFilePath uses daily log file names by channel', () => {
assert.match(resolveLogFilePath('app', '2026-05-28T00:00:19.194+08:00'), /app-2026-05-28\.log$/)
assert.match(resolveLogFilePath('integration', '2026-05-28T00:00:19.194+08:00'), /integration-2026-05-28\.log$/)
assert.match(
resolveLogFilePath('integration', '2026-05-28T00:00:19.194+08:00'),
/integration-2026-05-28\.log$/,
)
})
test('resolveLogFilePath uses local date for utc timestamps', () => {
@@ -36,15 +39,18 @@ test('resolveLogFilePath uses local date for utc timestamps', () => {
})
test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => {
const expired = resolveExpiredLogFilenames([
'app-2026-05-30.log',
'app-2026-05-01.log',
'app-2026-04-30.log',
'integration-2026-04-29.log',
'app.log',
'integration.log',
'random.txt',
], '2026-05-30')
const expired = resolveExpiredLogFilenames(
[
'app-2026-05-30.log',
'app-2026-05-01.log',
'app-2026-04-30.log',
'integration-2026-04-29.log',
'app.log',
'integration.log',
'random.txt',
],
'2026-05-30',
)
assert.deepEqual(expired, [
'app-2026-04-30.log',
@@ -55,15 +61,13 @@ test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores
})
test('resolveExpiredLogFilenames supports explicit retention days', () => {
const expired = resolveExpiredLogFilenames([
'app-2026-04-14.log',
'app-2026-04-08.log',
'app-2026-04-07.log',
], '2026-04-14', 7)
const expired = resolveExpiredLogFilenames(
['app-2026-04-14.log', 'app-2026-04-08.log', 'app-2026-04-07.log'],
'2026-04-14',
7,
)
assert.deepEqual(expired, [
'app-2026-04-07.log',
])
assert.deepEqual(expired, ['app-2026-04-07.log'])
})
test('formatLogTimestamp renders readable local timestamp with timezone offset', () => {
@@ -72,18 +76,21 @@ test('formatLogTimestamp renders readable local timestamp with timezone offset',
})
test('formatLogEntry prints readable one-line output for flat details', () => {
const text = formatLogEntry({
time: '2026-04-14T10:36:24.974Z',
level: 'info',
scope: '[http/access]',
message: 'request completed',
pid: 4671,
detail: {
requestId: 'req-123',
method: 'GET',
statusCode: 401,
const text = formatLogEntry(
{
time: '2026-04-14T10:36:24.974Z',
level: 'info',
scope: '[http/access]',
message: 'request completed',
pid: 4671,
detail: {
requestId: 'req-123',
method: 'GET',
statusCode: 401,
},
},
}, { color: false })
{ color: false },
)
assert.match(text, /INFO\s+\[http\/access\] request completed/)
assert.match(text, /pid=4671/)
@@ -94,21 +101,24 @@ test('formatLogEntry prints readable one-line output for flat details', () => {
})
test('formatLogEntry prints nested detail blocks without ansi colors in file mode', () => {
const text = formatLogEntry({
time: '2026-04-14T10:36:24.974Z',
level: 'warn',
scope: '[admin/auth]',
message: '后台鉴权失败',
pid: 4671,
detail: {
statusCode: 401,
errorCode: 'admin_auth_required',
error: {
message: '未登录或登录已失效',
stack: 'Error: test',
const text = formatLogEntry(
{
time: '2026-04-14T10:36:24.974Z',
level: 'warn',
scope: '[admin/auth]',
message: '后台鉴权失败',
pid: 4671,
detail: {
statusCode: 401,
errorCode: 'admin_auth_required',
error: {
message: '未登录或登录已失效',
stack: 'Error: test',
},
},
},
}, { color: false })
{ color: false },
)
assert.match(text, /WARN\s+\[admin\/auth\] 后台鉴权失败/)
assert.match(text, /statusCode=401/)
@@ -119,24 +129,38 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod
})
test('formatLogEntry masks sensitive fields in inline and nested details', () => {
const text = formatLogEntry({
time: '2026-04-14T10:36:24.974Z',
level: 'info',
scope: '[security]',
message: 'masked',
pid: 4671,
detail: {
token: 'abcdef1234567890',
authorization: 'Bearer abcdef1234567890',
nested: {
cookie: 'sessionid=abcdef1234567890',
note: 'password=super-secret-value',
const text = formatLogEntry(
{
time: '2026-04-14T10:36:24.974Z',
level: 'info',
scope: '[security]',
message: 'masked',
pid: 4671,
detail: {
token: 'abcdef1234567890',
authorization: 'Bearer abcdef1234567890',
sign: '0123456789abcdef',
jumpLink:
'https://feifei.example.com/h5/bind?code=very-secret-code&token=very-secret-token',
rawText:
'{"token":"raw-json-token","sign":"raw-json-sign","h5":{"recharge_url":"https://feifei.example.com/h5/bind?code=raw-secret-code"}}',
nested: {
cookie: 'sessionid=abcdef1234567890',
note: 'password=super-secret-value',
},
},
},
}, { color: false })
{ color: false },
)
assert.doesNotMatch(text, /abcdef1234567890/)
assert.doesNotMatch(text, /super-secret-value/)
assert.doesNotMatch(text, /0123456789abcdef/)
assert.doesNotMatch(text, /very-secret-code/)
assert.doesNotMatch(text, /very-secret-token/)
assert.doesNotMatch(text, /raw-json-token/)
assert.doesNotMatch(text, /raw-json-sign/)
assert.doesNotMatch(text, /raw-secret-code/)
assert.match(text, /abcdef\*\*\*\*567890/)
assert.match(text, /Bearer\*\*\*\*567890/)
assert.match(text, /password=super-\*\*\*\*-value/)
+128 -29
View File
@@ -11,6 +11,7 @@ type LogLevel = 'debug' | 'info' | 'warn' | 'error'
type LogChannel = 'app' | 'integration'
type LogDetail = unknown
type InlineFields = Record<string, string | number | boolean | null | undefined>
type ExternalHttpPacketDetail = Record<string, unknown>
type LogEntry = {
time: string
level: LogLevel
@@ -51,7 +52,12 @@ const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
const DEFAULT_LOG_RETENTION_DAYS = 30
const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays)
const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log'])
const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i
const SENSITIVE_KEY_PATTERN =
/token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session|x-sign|(?:^|[_-])sign(?:ature)?(?:[_-]|$)/i
const SENSITIVE_URL_PARAM_PATTERN =
/([?&](?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key|sign|signature|code)=)([^&\s,;"']+)/gi
const SENSITIVE_JSON_FIELD_PATTERN =
/("(?:token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardNo|cardPwd|apiKey|api_key|deviceKey|device_key|session|x-sign|sign|signature)"\s*:\s*")([^"]*)(")/gi
const MAX_SANITIZE_DEPTH = 8
let writeQueue = Promise.resolve()
@@ -86,6 +92,15 @@ export function logIntegration(
return writeLog(level, scope, message, detail, { channel: 'integration' })
}
export function logExternalHttpPacket(
scope: unknown,
message: unknown,
detail: ExternalHttpPacketDetail = {},
{ level = 'info' }: { level?: LogLevel } = {},
) {
return logIntegration(scope, message, normalizeExternalHttpPacketDetail(detail), { level })
}
function writeLog(
level: LogLevel,
scope: unknown,
@@ -147,18 +162,26 @@ function resolveConsoleMethod(level: unknown) {
}
export function normalizeLogLevel(level: unknown): LogLevel {
const normalized = String(level || '').trim().toLowerCase()
const normalized = String(level || '')
.trim()
.toLowerCase()
return isLogLevel(normalized) ? normalized : 'info'
}
export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean {
export function shouldWriteLog(
level: unknown,
configuredLevel: unknown = ACTIVE_LOG_LEVEL,
): boolean {
const normalizedLevel = normalizeLogLevel(level)
const normalizedConfigured = normalizeLogLevel(configuredLevel)
return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured]
}
export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { color?: boolean } = {}): string {
export function formatLogEntry(
entry: Partial<LogEntry>,
{ color = false }: { color?: boolean } = {},
): string {
const normalizedEntry = {
time: String(entry?.time || new Date().toISOString()),
level: normalizeLogLevel(entry?.level),
@@ -168,7 +191,11 @@ export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { co
detail: sanitizeLogValue(entry?.detail),
}
const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color)
const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color)
const level = colorize(
LEVEL_LABELS[normalizedEntry.level],
resolveLevelColor(normalizedEntry.level),
color,
)
const scope = colorize(normalizedEntry.scope, ANSI.cyan, color)
const message = colorize(normalizedEntry.message, ANSI.bold, color)
const separator = colorize('|', ANSI.dim, color)
@@ -177,7 +204,9 @@ export function formatLogEntry(entry: Partial<LogEntry>, { color = false }: { co
pid: normalizedEntry.pid,
...inline,
})
const firstLine = [timestamp, level, scope, message, separator, inlineFields].filter(Boolean).join(' ')
const firstLine = [timestamp, level, scope, message, separator, inlineFields]
.filter(Boolean)
.join(' ')
if (!block) {
return firstLine
@@ -207,8 +236,9 @@ export function resolveExpiredLogFilenames(
const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays)
const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1)))
return (Array.isArray(fileNames) ? fileNames : [])
.filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey))
return (Array.isArray(fileNames) ? fileNames : []).filter((fileName) =>
shouldDeleteLogFileByDate(fileName, cutoffDateKey),
)
}
export function formatLogTimestamp(value: unknown): string {
@@ -232,27 +262,29 @@ function normalizeLogValue(value: unknown): unknown {
}
try {
return sanitizeLogValue(JSON.parse(
JSON.stringify(value, (_key, current) => {
if (current instanceof Error) {
const currentError = current as Error & HttpErrorLike
return {
name: current.name,
message: sanitizeLogString(current.message),
stack: sanitizeLogString(current.stack),
statusCode: currentError.statusCode,
errorCode: currentError.errorCode,
context: sanitizeLogValue(currentError.context),
return sanitizeLogValue(
JSON.parse(
JSON.stringify(value, (_key, current) => {
if (current instanceof Error) {
const currentError = current as Error & HttpErrorLike
return {
name: current.name,
message: sanitizeLogString(current.message),
stack: sanitizeLogString(current.stack),
statusCode: currentError.statusCode,
errorCode: currentError.errorCode,
context: sanitizeLogValue(currentError.context),
}
}
}
if (typeof current === 'bigint') {
return String(current)
}
if (typeof current === 'bigint') {
return String(current)
}
return current
}),
))
return current
}),
),
)
} catch {
return sanitizeLogString(String(value))
}
@@ -305,7 +337,18 @@ function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown {
function sanitizeLogString(value: unknown): string {
return String(value || '')
.replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`)
.replace(
/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi,
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
)
.replace(
SENSITIVE_URL_PARAM_PATTERN,
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
)
.replace(
SENSITIVE_JSON_FIELD_PATTERN,
(_matched, prefix, secret, suffix) => `${prefix}${maskSecret(secret)}${suffix}`,
)
.replace(
/\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi,
(_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`,
@@ -316,7 +359,61 @@ function isSensitiveLogKey(key: unknown): boolean {
return SENSITIVE_KEY_PATTERN.test(String(key || '').trim())
}
function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } {
function normalizeExternalHttpPacketDetail(
detail: ExternalHttpPacketDetail,
): ExternalHttpPacketDetail {
const source = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : {}
const normalized: ExternalHttpPacketDetail = {}
for (const [key, value] of Object.entries(source)) {
normalized[key] = normalizeExternalPacketValue(value)
}
return normalized
}
function normalizeExternalPacketValue(value: unknown): unknown {
if (typeof value === 'undefined' || value === null) {
return value
}
if (value instanceof Error) {
const currentError = value as Error & HttpErrorLike
return {
name: value.name,
message: value.message,
stack: value.stack,
statusCode: currentError.statusCode,
errorCode: currentError.errorCode,
context: normalizeExternalPacketValue(currentError.context),
}
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return value
}
if (Array.isArray(value)) {
return value.map((item) => normalizeExternalPacketValue(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [
key,
normalizeExternalPacketValue(currentValue),
]),
)
}
return String(value)
}
function splitDetailPayload(detail: unknown): { inline: InlineFields; block: unknown | null } {
if (typeof detail === 'undefined') {
return {
inline: {},
@@ -446,7 +543,9 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise<void> {
try {
const fileNames = await fs.readdir(LOG_DIR)
const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS)
await Promise.all(expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })))
await Promise.all(
expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true })),
)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error || '未知错误')
console.error('[logger] failed to cleanup old log files:', reason)