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