优化日志切分和保留策略

This commit is contained in:
yml
2026-05-28 12:32:13 +08:00
parent 26d40a2c13
commit e4c9873c8d
10 changed files with 99 additions and 22 deletions
+1
View File
@@ -19,6 +19,7 @@ POSTGRES_PORT=5432
# Logging
LOG_LEVEL=info
LOG_RETENTION_DAYS=30
# Admin
ADMIN_SESSION_SECRET=dev-local-session-secret
+1
View File
@@ -18,6 +18,7 @@ DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order
# Logging
LOG_LEVEL=info
LOG_RETENTION_DAYS=30
# Admin
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
+1
View File
@@ -14,6 +14,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
logging: {
level: "info",
retentionDays: 30,
},
database: {
@@ -12,6 +12,7 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
PORT: '4000',
DATA_ROOT: './tmp/backend-data',
DATABASE_SSL: 'yes',
LOG_RETENTION_DAYS: '45',
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
KAQUAN91_USER_ID: 'kaquan-user',
KAQUAN91_SECRET: 'kaquan-secret',
@@ -21,6 +22,7 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
assert.equal(config.server.port, 4000)
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
assert.equal(config.database.ssl, true)
assert.equal(config.logging.retentionDays, 45)
assert.deepEqual(config.admin.defaultUsers, [
{ username: 'admin', password: 'secret', role: 'admin' },
])
@@ -55,12 +57,14 @@ test('applyEnvOverrides ignores empty or invalid values', () => {
DATABASE_SSL: 'maybe',
ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}',
LOG_LEVEL: ' ',
LOG_RETENTION_DAYS: 'not-a-number',
})
assert.equal(config.server.port, 3000)
assert.equal(config.database.ssl, false)
assert.deepEqual(config.admin.defaultUsers, [])
assert.equal(config.logging.level, 'info')
assert.equal(config.logging.retentionDays, 30)
})
test('ENV_OVERRIDES keeps environment variable names unique', () => {
@@ -78,6 +82,7 @@ function createRuntimeConfig(): RuntimeConfig {
},
logging: {
level: 'info',
retentionDays: 30,
},
database: {
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
+1
View File
@@ -31,6 +31,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
integerEnv("PORT", ["server", "port"]),
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
stringEnv("LOG_LEVEL", ["logging", "level"]),
integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]),
stringEnv("DATABASE_URL", ["database", "url"]),
booleanEnv("DATABASE_SSL", ["database", "ssl"]),
integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]),
+1
View File
@@ -14,6 +14,7 @@ export type RuntimeConfig = {
};
logging: {
level: string;
retentionDays: number;
};
database: {
url: string;
+27 -12
View File
@@ -27,27 +27,42 @@ test('shouldWriteLog respects configured minimum log level', () => {
})
test('resolveLogFilePath uses daily log file names by channel', () => {
assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/)
assert.match(resolveLogFilePath('integration', '2026-04-14T10:00:00.000Z'), /integration-2026-04-14\.log$/)
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$/)
})
test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => {
test('resolveLogFilePath uses local date for utc timestamps', () => {
assert.match(resolveLogFilePath('app', '2026-05-27T16:00:19.194Z'), /app-2026-05-28\.log$/)
})
test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => {
const expired = resolveExpiredLogFilenames([
'app-2026-04-14.log',
'app-2026-04-13.log',
'app-2026-04-08.log',
'app-2026-04-07.log',
'integration-2026-04-06.log',
'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-04-14T12:00:00.000Z', 7)
], '2026-05-30')
assert.deepEqual(expired, [
'app-2026-04-30.log',
'integration-2026-04-29.log',
'app.log',
'integration.log',
])
})
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)
assert.deepEqual(expired, [
'app-2026-04-07.log',
'integration-2026-04-06.log',
'app.log',
'integration.log',
])
})
+41 -10
View File
@@ -48,7 +48,8 @@ const ANSI = {
const DATA_ROOT = resolveDataRoot()
const LOG_DIR = path.join(DATA_ROOT, 'logs')
const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level)
const LOG_RETENTION_DAYS = 7
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 MAX_SANITIZE_DEPTH = 8
@@ -203,7 +204,7 @@ export function resolveExpiredLogFilenames(
referenceTime = new Date().toISOString(),
retentionDays = LOG_RETENTION_DAYS,
): string[] {
const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS)
const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays)
const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1)))
return (Array.isArray(fileNames) ? fileNames : [])
@@ -444,7 +445,7 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise<void> {
try {
const fileNames = await fs.readdir(LOG_DIR)
const expired = resolveExpiredLogFilenames(fileNames, `${dateKey}T00:00:00.000Z`)
const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS)
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 || '未知错误')
@@ -453,26 +454,56 @@ async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise<void> {
}
function extractLogDateKey(time: unknown): string {
const normalized = String(time || '').trim()
return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized)
return toDateKey(time)
}
function toDateKey(value: unknown): string {
const date = new Date(value instanceof Date ? value : String(value || ''))
const date = parseLogDate(value)
if (Number.isNaN(date.getTime())) {
return new Date().toISOString().slice(0, 10)
return formatLocalDate(new Date())
}
return date.toISOString().slice(0, 10)
return formatLocalDate(date)
}
function offsetDate(value: unknown, offsetDays: unknown): Date {
const date = new Date(value instanceof Date ? value : String(value || ''))
date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0))
const date = parseLogDate(value)
if (Number.isNaN(date.getTime())) {
return new Date()
}
date.setDate(date.getDate() + Number(offsetDays || 0))
return date
}
function parseLogDate(value: unknown): Date {
if (value instanceof Date) {
return new Date(value.getTime())
}
const text = String(value || '').trim()
const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (matchedDateKey) {
return new Date(
Number(matchedDateKey[1]),
Number(matchedDateKey[2]) - 1,
Number(matchedDateKey[3]),
)
}
return new Date(text)
}
function normalizeLogRetentionDays(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DEFAULT_LOG_RETENTION_DAYS
}
return Math.max(1, Math.floor(parsed))
}
function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean {
if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) {
return true
+11
View File
@@ -1,7 +1,14 @@
x-json-log-rotation: &json-log-rotation
driver: json-file
options:
max-size: "20m"
max-file: "5"
services:
postgres:
image: docker.m.daocloud.io/library/postgres:16-bookworm
restart: unless-stopped
logging: *json-log-rotation
environment:
TZ: ${TZ:-Asia/Shanghai}
POSTGRES_DB: ${POSTGRES_DB:-order_site}
@@ -47,9 +54,11 @@ services:
DATABASE_SSL: ${DATABASE_SSL:-false}
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
DATA_ROOT: /app/data
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
logging: *json-log-rotation
volumes:
- ./apps/backend:/app
- backend_node_modules:/app/node_modules
@@ -59,6 +68,7 @@ services:
image: docker.m.daocloud.io/library/node:22-bookworm-slim
working_dir: /app
restart: unless-stopped
logging: *json-log-rotation
command: >-
sh -lc
"[ -f node_modules/.install_done ] || (npm install --no-fund --no-audit && touch node_modules/.install_done);
@@ -76,6 +86,7 @@ services:
web:
image: docker.m.daocloud.io/library/caddy:2-alpine
restart: unless-stopped
logging: *json-log-rotation
depends_on:
backend:
condition: service_healthy
+10
View File
@@ -1,7 +1,14 @@
x-json-log-rotation: &json-log-rotation
driver: json-file
options:
max-size: "20m"
max-file: "5"
services:
postgres:
image: docker.m.daocloud.io/library/postgres:16-bookworm
restart: unless-stopped
logging: *json-log-rotation
environment:
TZ: ${TZ:-Asia/Shanghai}
POSTGRES_DB: ${POSTGRES_DB:-order_site}
@@ -43,9 +50,11 @@ services:
DATABASE_SSL: ${DATABASE_SSL:-false}
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-20}
DATA_ROOT: /app/data
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
logging: *json-log-rotation
volumes:
- ./apps/backend/data:/app/data
@@ -56,6 +65,7 @@ services:
args:
APP_DOMAIN: ${APP_DOMAIN:-localhost}
restart: unless-stopped
logging: *json-log-rotation
depends_on:
backend:
condition: service_healthy